Merge branch 'int' into feat/demo-system-readieness
This commit is contained in:
commit
bb9fd56bc6
42 changed files with 3149 additions and 1194 deletions
|
|
@ -36,6 +36,29 @@ export interface BillingTransaction {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
|
||||||
|
export interface BillingTransactionsPaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingTransactionsPaginatedResponse {
|
||||||
|
items: BillingTransaction[];
|
||||||
|
pagination?: {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface BillingSettings {
|
export interface BillingSettings {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
@ -135,7 +158,31 @@ export async function fetchBalanceForMandate(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch transaction history
|
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
|
||||||
|
* Endpoint: GET /api/billing/transactions?pagination=...
|
||||||
|
*/
|
||||||
|
export async function fetchTransactionsPaginated(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params?: BillingTransactionsPaginationParams
|
||||||
|
): Promise<BillingTransactionsPaginatedResponse> {
|
||||||
|
const paginationObj: Record<string, unknown> = {};
|
||||||
|
if (params?.page !== undefined) paginationObj.page = params.page;
|
||||||
|
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||||
|
if (params?.sort?.length) paginationObj.sort = params.sort;
|
||||||
|
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
|
||||||
|
if (params?.search) paginationObj.search = params.search;
|
||||||
|
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
|
return await request({
|
||||||
|
url: '/api/billing/transactions',
|
||||||
|
method: 'get',
|
||||||
|
params: { pagination: JSON.stringify(paginationObj) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch transaction history (legacy array window)
|
||||||
* Endpoint: GET /api/billing/transactions
|
* Endpoint: GET /api/billing/transactions
|
||||||
*/
|
*/
|
||||||
export async function fetchTransactions(
|
export async function fetchTransactions(
|
||||||
|
|
|
||||||
|
|
@ -55,19 +55,22 @@ 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). */
|
/** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */
|
||||||
groupId?: string;
|
viewKey?: string;
|
||||||
/** If set, persist this group tree on the backend before fetching (optimistic save). */
|
/** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */
|
||||||
saveGroupTree?: TableGroupNode[];
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableGroupNode {
|
export interface GroupBand {
|
||||||
id: string;
|
path: string[];
|
||||||
name: string;
|
label: string;
|
||||||
itemIds: string[];
|
startRowIndex: number;
|
||||||
subGroups: TableGroupNode[];
|
rowCount: number;
|
||||||
order: number;
|
}
|
||||||
isExpanded: boolean;
|
|
||||||
|
export interface GroupLayout {
|
||||||
|
levels: string[];
|
||||||
|
bands: GroupBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -78,8 +81,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. */
|
groupLayout?: GroupLayout;
|
||||||
groupTree?: TableGroupNode[];
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateConnectionData {
|
export interface CreateConnectionData {
|
||||||
|
|
@ -138,8 +141,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.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -34,8 +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;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -46,6 +46,8 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type for the request function passed to API functions
|
// Type for the request function passed to API functions
|
||||||
|
|
@ -105,9 +107,9 @@ 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.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
}
|
}
|
||||||
|
|
@ -249,28 +251,13 @@ export async function deleteGroup(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect all file IDs belonging to a group recursively (client-side, from known groupTree) */
|
/** @deprecated Group tree removed — use view-based grouping (viewKey). Returns empty array. */
|
||||||
export function collectGroupItemIds(
|
export function collectGroupItemIds(
|
||||||
groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
|
_groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
|
||||||
groupId: string
|
_groupId: string
|
||||||
): string[] {
|
): string[] {
|
||||||
const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => {
|
const collect = (): string[] | null => null;
|
||||||
for (const node of nodes) {
|
return collect() ?? [];
|
||||||
if (node.id === groupId) {
|
|
||||||
const ids: string[] = [...node.itemIds];
|
|
||||||
const sub = (n: { id: string; itemIds: string[]; subGroups: any[] }) => {
|
|
||||||
ids.push(...n.itemIds);
|
|
||||||
n.subGroups.forEach(sub);
|
|
||||||
};
|
|
||||||
node.subGroups.forEach(sub);
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
const found = collect(node.subGroups);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return collect(groupTree) ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The following operations require special handling (FormData, blob responses)
|
// Note: The following operations require special handling (FormData, blob responses)
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,7 @@ 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;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -86,8 +85,7 @@ 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.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
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,8 +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;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -61,6 +61,8 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePromptData {
|
export interface CreatePromptData {
|
||||||
|
|
@ -112,9 +114,9 @@ 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.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree;
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
src/api/tableViewApi.ts
Normal file
59
src/api/tableViewApi.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export interface TableListViewRow {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
mandateId?: string | null;
|
||||||
|
contextKey: string;
|
||||||
|
viewKey: string;
|
||||||
|
displayName: string;
|
||||||
|
config: TableViewConfig;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableViewConfig {
|
||||||
|
schemaVersion?: number;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string }>;
|
||||||
|
/** Section mode (`tableGroupLayoutMode="sections"`): stable keys (`sk`) of collapsed sections. */
|
||||||
|
collapsedSectionKeys?: string[];
|
||||||
|
/** Inline `groupLayout` bands: keys are `band.path.join('///')`. */
|
||||||
|
collapsedGroupKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTableViews(contextKey: string): Promise<TableListViewRow[]> {
|
||||||
|
const { data } = await api.get<TableListViewRow[]>('/api/table-views', {
|
||||||
|
params: { contextKey },
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTableView(contextKey: string, viewKey: string): Promise<TableListViewRow> {
|
||||||
|
const { data } = await api.get<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewKey)}`, {
|
||||||
|
params: { contextKey },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTableView(payload: {
|
||||||
|
contextKey: string;
|
||||||
|
viewKey: string;
|
||||||
|
displayName: string;
|
||||||
|
config: TableViewConfig;
|
||||||
|
}): Promise<TableListViewRow> {
|
||||||
|
const { data } = await api.post<TableListViewRow>('/api/table-views', payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTableView(
|
||||||
|
viewId: string,
|
||||||
|
updates: { displayName?: string; viewKey?: string; config?: TableViewConfig },
|
||||||
|
): Promise<TableListViewRow> {
|
||||||
|
const { data } = await api.put<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewId)}`, updates);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTableView(viewId: string): Promise<void> {
|
||||||
|
await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`);
|
||||||
|
}
|
||||||
|
|
@ -48,8 +48,7 @@ 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;
|
viewKey?: string;
|
||||||
saveGroupTree?: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -154,8 +153,7 @@ 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.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
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 {
|
||||||
|
|
@ -279,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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -99,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('');
|
||||||
|
|
@ -459,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([]);
|
||||||
|
|
@ -904,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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,11 +28,11 @@ 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 { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
||||||
import { getApiBaseUrl } from '../../../../../config/config';
|
import { getApiBaseUrl } from '../../../../../config/config';
|
||||||
|
|
@ -535,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));
|
||||||
|
|
@ -543,62 +548,98 @@ 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)')}
|
||||||
<option value="group">{t('Gruppe')}</option>
|
value={String(f.label ?? '')}
|
||||||
</select>
|
onChange={(e) => updateField(i, 'label', e.target.value)}
|
||||||
<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' }} />
|
style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
|
||||||
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
|
/>
|
||||||
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
|
<button
|
||||||
</label>
|
type="button"
|
||||||
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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)} />
|
||||||
|
Pflicht
|
||||||
|
</label>
|
||||||
|
</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 }}>
|
||||||
<input
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
type="text"
|
<input
|
||||||
placeholder={t('Name')}
|
type="text"
|
||||||
value={String(sub.name ?? '')}
|
placeholder={t('Name')}
|
||||||
onChange={(e) => {
|
value={String(sub.name ?? '')}
|
||||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
onChange={(e) => {
|
||||||
nextFields[j] = { ...sub, name: e.target.value };
|
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
||||||
updateField(i, 'fields', nextFields);
|
nextFields[j] = { ...sub, name: e.target.value };
|
||||||
}}
|
updateField(i, 'fields', nextFields);
|
||||||
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
}}
|
||||||
/>
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
<select
|
/>
|
||||||
value={String(sub.type ?? 'text')}
|
<select
|
||||||
onChange={(e) => {
|
value={String(sub.type ?? 'text')}
|
||||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
onChange={(e) => {
|
||||||
nextFields[j] = { ...sub, type: e.target.value };
|
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
|
||||||
updateField(i, 'fields', nextFields);
|
nextFields[j] = { ...sub, type: e.target.value };
|
||||||
}}
|
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 => (
|
>
|
||||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
{fieldTypeOptions.map((ft) => (
|
||||||
))}
|
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||||
</select>
|
))}
|
||||||
<button
|
</select>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
|
onClick={() => {
|
||||||
updateField(i, 'fields', nextFields);
|
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
|
||||||
}}
|
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>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
|
|
@ -607,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -865,6 +912,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, FaLayerGroup } from "react-icons/fa";
|
import { FaTrash, FaDownload } 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,10 +77,6 @@ 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({
|
||||||
|
|
@ -114,9 +110,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -186,9 +179,15 @@ export function FormGeneratorControls({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search Controls with Pagination - Hide when items are selected */}
|
{/* Toolbar: optional search + filters badge + CSV + pagination (search is optional) */}
|
||||||
{searchable && selectedCount === 0 && (
|
{selectedCount === 0 &&
|
||||||
|
(searchable ||
|
||||||
|
(pagination && supportsBackendPagination) ||
|
||||||
|
!!onCsvExport ||
|
||||||
|
!!onRefresh ||
|
||||||
|
activeFiltersCount > 0) && (
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
|
{searchable && (
|
||||||
<div className={styles.floatingLabelInput}>
|
<div className={styles.floatingLabelInput}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -203,6 +202,7 @@ export function FormGeneratorControls({
|
||||||
{t('Suchen...')}
|
{t('Suchen...')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<span className={styles.activeFiltersCount}>
|
<span className={styles.activeFiltersCount}>
|
||||||
{activeFiltersCount} {t('Filter')}
|
{activeFiltersCount} {t('Filter')}
|
||||||
|
|
@ -219,16 +219,6 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Outer table in “sections” mode: fill flex parent (e.g. billing transactions tab) */
|
||||||
|
.formGeneratorTableSectionsRoot {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
@ -79,6 +85,93 @@
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Group sections layout (one table per category) ───────────────────── */
|
||||||
|
.groupSections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
/* Share remaining viewport among expanded groups; scroll when many groups */
|
||||||
|
flex: 1 1 280px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionCollapsed {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 4px 4px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionHeader:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-bg, #fff) 92%, var(--color-border, #e2e8f0) 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionHeaderLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionCaret {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionMeta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionsLoading {
|
||||||
|
padding: 12px 4px;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSectionTableWrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.emptyMessage {
|
.emptyMessage {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
@ -1237,3 +1330,69 @@ tbody .actionsColumn {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Group bands (server-side view grouping — ClickUp-style) */
|
||||||
|
.groupBandHeaderRow {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: color-mix(in srgb, var(--color-bg, #fff) 88%, var(--color-border, #e2e8f0) 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandHeaderCell {
|
||||||
|
padding: 8px 14px !important;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandInner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandCaret {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandPill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: min(420px, 72%);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.25;
|
||||||
|
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 16%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-primary, #2f4364) 95%, #fff);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandPath {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandPathSep {
|
||||||
|
opacity: 0.45;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupBandCount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,9 @@ import {
|
||||||
FaChevronRight,
|
FaChevronRight,
|
||||||
FaUnlink,
|
FaUnlink,
|
||||||
FaSyncAlt,
|
FaSyncAlt,
|
||||||
|
FaFolderPlus,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import type {
|
import type {
|
||||||
TreeNode,
|
TreeNode,
|
||||||
TreeNodeProvider,
|
TreeNodeProvider,
|
||||||
|
|
@ -83,6 +85,15 @@ function _flatten<T>(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _resolveNewFolderParentId<T>(selectedIds: Set<string>, nodes: TreeNode<T>[]): string | null {
|
||||||
|
if (selectedIds.size !== 1) return null;
|
||||||
|
const id = [...selectedIds][0];
|
||||||
|
const node = nodes.find((n) => n.id === id);
|
||||||
|
if (!node) return null;
|
||||||
|
if (node.type === 'folder') return node.id;
|
||||||
|
return node.parentId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function _collectDescendantIds<T>(nodeId: string, nodes: TreeNode<T>[]): string[] {
|
function _collectDescendantIds<T>(nodeId: string, nodes: TreeNode<T>[]): string[] {
|
||||||
const childMap = _buildChildMap(nodes);
|
const childMap = _buildChildMap(nodes);
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
|
|
@ -583,6 +594,32 @@ export function FormGeneratorTree<T = any>({
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
}, [_loadRoot, _updateSelection, onRefresh]);
|
}, [_loadRoot, _updateSelection, onRefresh]);
|
||||||
|
|
||||||
|
const _handleNewFolder = useCallback(async () => {
|
||||||
|
if (ownership !== 'own' || !provider.createChild || !allowCreateFolder) return;
|
||||||
|
const parentId = _resolveNewFolderParentId(selectedIds, nodes);
|
||||||
|
if (provider.canCreate && !provider.canCreate(parentId)) return;
|
||||||
|
const name = await prompt('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
|
const trimmed = name?.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
try {
|
||||||
|
const newNode = await provider.createChild(parentId, trimmed);
|
||||||
|
setNodes((prev) => [...prev, newNode]);
|
||||||
|
if (parentId) {
|
||||||
|
setExpandedIds((prev) => new Set(prev).add(parentId));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await _handleRefresh();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
ownership,
|
||||||
|
provider,
|
||||||
|
allowCreateFolder,
|
||||||
|
selectedIds,
|
||||||
|
nodes,
|
||||||
|
prompt,
|
||||||
|
_handleRefresh,
|
||||||
|
]);
|
||||||
|
|
||||||
const _handleDelete = useCallback(
|
const _handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const node = nodes.find((n) => n.id === id);
|
const node = nodes.find((n) => n.id === id);
|
||||||
|
|
@ -811,6 +848,13 @@ export function FormGeneratorTree<T = any>({
|
||||||
|
|
||||||
const totalNodeCount = nodes.filter((n) => n.parentId === null).length;
|
const totalNodeCount = nodes.filter((n) => n.parentId === null).length;
|
||||||
|
|
||||||
|
const showNewFolderButton =
|
||||||
|
Boolean(title) &&
|
||||||
|
ownership === 'own' &&
|
||||||
|
allowCreateFolder &&
|
||||||
|
Boolean(provider.createChild) &&
|
||||||
|
(provider.canCreate?.(_resolveNewFolderParentId(selectedIds, nodes)) ?? true);
|
||||||
|
|
||||||
const wrapperClasses = [
|
const wrapperClasses = [
|
||||||
styles.formGeneratorTree,
|
styles.formGeneratorTree,
|
||||||
compact && styles.compactMode,
|
compact && styles.compactMode,
|
||||||
|
|
@ -821,7 +865,6 @@ export function FormGeneratorTree<T = any>({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={wrapperClasses}>
|
<div className={wrapperClasses}>
|
||||||
<ConfirmDialog />
|
|
||||||
{title && (
|
{title && (
|
||||||
<div
|
<div
|
||||||
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
|
className={`${styles.sectionHeader} ${collapsible ? '' : styles.sectionHeaderNonCollapsible}`}
|
||||||
|
|
@ -836,6 +879,20 @@ export function FormGeneratorTree<T = any>({
|
||||||
)}
|
)}
|
||||||
<span className={styles.sectionTitle}>{title}</span>
|
<span className={styles.sectionTitle}>{title}</span>
|
||||||
<span className={styles.sectionCount}>{totalNodeCount}</span>
|
<span className={styles.sectionCount}>{totalNodeCount}</span>
|
||||||
|
{showNewFolderButton && (
|
||||||
|
<button
|
||||||
|
className={styles.refreshBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
_handleNewFolder();
|
||||||
|
}}
|
||||||
|
title="Neuer Ordner"
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<FaFolderPlus />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={styles.refreshBtn}
|
className={styles.refreshBtn}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -843,6 +900,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
_handleRefresh();
|
_handleRefresh();
|
||||||
}}
|
}}
|
||||||
title="Aktualisieren"
|
title="Aktualisieren"
|
||||||
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<FaSyncAlt />
|
<FaSyncAlt />
|
||||||
|
|
@ -956,6 +1014,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,21 @@
|
||||||
// All rights reserved.
|
// All rights reserved.
|
||||||
|
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||||
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
|
||||||
|
|
||||||
|
const { mockPrompt } = vi.hoisted(() => ({
|
||||||
|
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/usePrompt', () => ({
|
||||||
|
usePrompt: () => ({
|
||||||
|
prompt: mockPrompt,
|
||||||
|
PromptDialog: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -90,6 +100,11 @@ function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
|
||||||
|
|
||||||
describe('FormGeneratorTree', () => {
|
describe('FormGeneratorTree', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrompt.mockClear();
|
||||||
|
mockPrompt.mockResolvedValue('NeuOrdner');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders tree with title and node count', async () => {
|
it('renders tree with title and node count', async () => {
|
||||||
const provider = _createMockProvider([_ownFolder]);
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
render(
|
render(
|
||||||
|
|
@ -174,6 +189,85 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// New folder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('New folder', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrompt.mockClear();
|
||||||
|
mockPrompt.mockResolvedValue('NeuOrdner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows header button when titled own tree has createChild', async () => {
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" title="Documents" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByTitle('Neuer Ordner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show new folder for shared tree', async () => {
|
||||||
|
const provider = _createMockProvider([_sharedFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="shared" title="Shared" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls createChild at root when nothing selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(screen.getByTitle('Neuer Ordner'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.createChild).toHaveBeenCalledWith(null, 'NeuOrdner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls createChild under selected folder', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
|
await user.click(screen.getByTitle('Neuer Ordner'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(provider.createChild).toHaveBeenCalledWith('f1', 'NeuOrdner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides button when allowCreateFolder is false', async () => {
|
||||||
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
render(
|
||||||
|
<FormGeneratorTree
|
||||||
|
provider={provider}
|
||||||
|
ownership="own"
|
||||||
|
title="Docs"
|
||||||
|
allowCreateFolder={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Selection
|
// Selection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -228,8 +322,8 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
fireEvent.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
await user.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
|
fireEvent.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -238,7 +332,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(lastCall.has('f2')).toBe(true);
|
expect(lastCall.has('f2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('click on selected folder cascades deselect of descendants (own)', async () => {
|
it('second click on folder with cascaded child selection keeps cascaded selection (own)', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSelectionChange = vi.fn();
|
const onSelectionChange = vi.fn();
|
||||||
const provider = _createMockProvider([_ownFolder, _ownFile]);
|
const provider = _createMockProvider([_ownFolder, _ownFile]);
|
||||||
|
|
@ -270,12 +364,11 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(lastCall.has('f1')).toBe(true);
|
expect(lastCall.has('f1')).toBe(true);
|
||||||
expect(lastCall.has('file1')).toBe(true);
|
expect(lastCall.has('file1')).toBe(true);
|
||||||
|
|
||||||
// Click again to deselect
|
|
||||||
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
|
||||||
|
|
||||||
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
|
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
|
||||||
expect(lastCall.has('f1')).toBe(false);
|
expect(lastCall.has('f1')).toBe(true);
|
||||||
expect(lastCall.has('file1')).toBe(false);
|
expect(lastCall.has('file1')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selection in shared tree does NOT cascade to children', async () => {
|
it('selection in shared tree does NOT cascade to children', async () => {
|
||||||
|
|
@ -455,6 +548,13 @@ describe('FormGeneratorTree', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Delete', () => {
|
describe('Delete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('delete button calls provider.deleteNodes', async () => {
|
it('delete button calls provider.deleteNodes', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const provider = _createMockProvider([_ownFolder]);
|
const provider = _createMockProvider([_ownFolder]);
|
||||||
|
|
@ -465,7 +565,7 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = screen.getByRole('treeitem', { name: /My Folder/i });
|
const row = screen.getByRole('treeitem', { name: /My Folder/i });
|
||||||
const deleteBtn = within(row).getByTitle('Delete');
|
const deleteBtn = within(row).getByTitle('Loeschen');
|
||||||
await user.click(deleteBtn);
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -482,7 +582,7 @@ describe('FormGeneratorTree', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
|
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
|
||||||
expect(within(row).queryByTitle('Delete')).not.toBeInTheDocument();
|
expect(within(row).queryByTitle('Loeschen')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -541,7 +641,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const neutralizeBtn = screen.getByTitle('Not neutralized');
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
await user.click(neutralizeBtn);
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -562,7 +662,7 @@ describe('FormGeneratorTree', () => {
|
||||||
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const neutralizeBtn = screen.getByTitle('Not neutralized');
|
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
|
||||||
await user.click(neutralizeBtn);
|
await user.click(neutralizeBtn);
|
||||||
|
|
||||||
expect(provider.patchNeutralize).not.toHaveBeenCalled();
|
expect(provider.patchNeutralize).not.toHaveBeenCalled();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
import { FormGeneratorTree } from '../FormGeneratorTree';
|
import { FormGeneratorTree } from '../FormGeneratorTree';
|
||||||
import type { TreeNode, TreeNodeProvider } from '../types';
|
import type { TreeNode, TreeNodeProvider } from '../types';
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/usePrompt', () => ({
|
||||||
|
usePrompt: () => ({
|
||||||
|
prompt: vi.fn(() => Promise.resolve('x')),
|
||||||
|
PromptDialog: () => null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixtures
|
// Fixtures
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
|
||||||
return nodes;
|
return nodes;
|
||||||
},
|
},
|
||||||
|
|
||||||
canCreate() {
|
canCreate(_parentId: string | null) {
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,7 @@ export interface FormGeneratorTreeProps<T = any> {
|
||||||
onSelectionChange?: (selectedIds: Set<string>) => void;
|
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onSendToChat?: (node: TreeNode<T>) => void;
|
onSendToChat?: (node: TreeNode<T>) => void;
|
||||||
|
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
|
||||||
|
allowCreateFolder?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { useConfirm } from '../../../hooks/useConfirm';
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import styles from './GroupRow.module.css';
|
import styles from './GroupRow.module.css';
|
||||||
import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css';
|
import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css';
|
||||||
import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable';
|
|
||||||
|
/** Legacy folder-tree row model (client-side group tree); kept for GroupFolderRow typings. */
|
||||||
|
export interface TableGroupNode {
|
||||||
|
name: string;
|
||||||
|
itemIds: string[];
|
||||||
|
}
|
||||||
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
|
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverAnchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTrigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupIcon {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTrigger:hover {
|
||||||
|
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
|
||||||
|
border-color: var(--color-primary, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTriggerOpen {
|
||||||
|
border-color: var(--color-primary, #4a6fa5);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 4200;
|
||||||
|
min-width: min(360px, calc(100vw - 24px));
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
background: var(--color-bg, #ffffff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverTitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popoverHint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 118px 36px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select,
|
||||||
|
.selectOrder {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:disabled,
|
||||||
|
.selectOrder:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover:not(:disabled) {
|
||||||
|
color: #fecaca;
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addLevelBtn {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed var(--color-border, #475569);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addLevelBtn:hover {
|
||||||
|
border-color: var(--color-primary, #4a6fa5);
|
||||||
|
color: var(--color-primary, #7dd3fc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeSummary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewSelect {
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnGhost {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text, #334155);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnGhost:hover {
|
||||||
|
background: var(--bg-hover, #f1f5f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDangerGhost {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
background: transparent;
|
||||||
|
color: #b91c1c;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnDangerGhost:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary, #4a6fa5);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
z-index: 4500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text, #0f172a);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHint {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalField {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalField label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalField input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal file
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './TableViewsBar.module.css';
|
||||||
|
|
||||||
|
export interface TableViewOption {
|
||||||
|
id: string;
|
||||||
|
viewKey: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One grouping level (ClickUp-style): column + band order for that level. */
|
||||||
|
export interface GroupByLevelSpec {
|
||||||
|
field: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableViewsBarProps {
|
||||||
|
views: TableViewOption[];
|
||||||
|
loadingViews: boolean;
|
||||||
|
activeViewKey: string | null;
|
||||||
|
activeViewId: string | null;
|
||||||
|
groupByLevels: GroupByLevelSpec[];
|
||||||
|
onGroupByLevelsChange: (levels: GroupByLevelSpec[]) => void;
|
||||||
|
onSelectView: (viewKey: string | null) => void;
|
||||||
|
columnOptions: Array<{ key: string; label: string }>;
|
||||||
|
onCreateView: (displayName: string, viewKey: string) => void | Promise<void>;
|
||||||
|
/** When a saved view is active, overwrite its config (filters, sort, grouping, folds). Optional. */
|
||||||
|
onSaveActiveView?: () => void | Promise<void>;
|
||||||
|
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
|
||||||
|
onDeleteView?: (viewId: string) => void | Promise<void>;
|
||||||
|
onReloadViews: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-_]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || 'view';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupLevelsToApiPayload(levels: GroupByLevelSpec[]) {
|
||||||
|
return levels
|
||||||
|
.filter((l) => l.field)
|
||||||
|
.map((l) => ({ field: l.field, nullLabel: '—', direction: l.direction }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitLevels(
|
||||||
|
next: GroupByLevelSpec[],
|
||||||
|
activeViewId: string | null,
|
||||||
|
onGroupByLevelsChange: (l: GroupByLevelSpec[]) => void,
|
||||||
|
onUpdateViewGrouping: (id: string, l: GroupByLevelSpec[]) => void | Promise<void>,
|
||||||
|
) {
|
||||||
|
onGroupByLevelsChange(next);
|
||||||
|
if (activeViewId) {
|
||||||
|
void Promise.resolve(onUpdateViewGrouping(activeViewId, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableViewsBar({
|
||||||
|
views,
|
||||||
|
loadingViews,
|
||||||
|
activeViewKey,
|
||||||
|
activeViewId,
|
||||||
|
groupByLevels,
|
||||||
|
onGroupByLevelsChange,
|
||||||
|
onSelectView,
|
||||||
|
columnOptions,
|
||||||
|
onCreateView,
|
||||||
|
onSaveActiveView,
|
||||||
|
onUpdateViewGrouping,
|
||||||
|
onDeleteView,
|
||||||
|
onReloadViews,
|
||||||
|
}: TableViewsBarProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupMenuOpen) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (el && !el.contains(e.target as Node)) setGroupMenuOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setGroupMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDoc);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [groupMenuOpen]);
|
||||||
|
|
||||||
|
const levelsForUi = useMemo(
|
||||||
|
() => (groupByLevels.length > 0 ? groupByLevels : [{ field: '', direction: 'asc' as const }]),
|
||||||
|
[groupByLevels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedFields = useMemo(
|
||||||
|
() => new Set(groupByLevels.map((l) => l.field).filter(Boolean)),
|
||||||
|
[groupByLevels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnsForRow = useCallback(
|
||||||
|
(_rowIdx: number, currentField: string) =>
|
||||||
|
columnOptions.filter((c) => c.key === currentField || !usedFields.has(c.key) || !c.key),
|
||||||
|
[columnOptions, usedFields],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [overwriteSaving, setOverwriteSaving] = useState(false);
|
||||||
|
|
||||||
|
const _onClickSave = useCallback(async () => {
|
||||||
|
if (activeViewId && onSaveActiveView) {
|
||||||
|
setOverwriteSaving(true);
|
||||||
|
try {
|
||||||
|
await onSaveActiveView();
|
||||||
|
await onReloadViews();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Save active view failed', e);
|
||||||
|
} finally {
|
||||||
|
setOverwriteSaving(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveOpen(true);
|
||||||
|
setNewName('');
|
||||||
|
}, [activeViewId, onSaveActiveView, onReloadViews]);
|
||||||
|
|
||||||
|
const _saveNew = async () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
const slug = slugify(name);
|
||||||
|
if (!name || !slug) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onCreateView(name, slug);
|
||||||
|
setSaveOpen(false);
|
||||||
|
setNewName('');
|
||||||
|
await onReloadViews();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLevel = (idx: number, patch: Partial<GroupByLevelSpec>) => {
|
||||||
|
const working = levelsForUi.map((l, i) => (i === idx ? { ...l, ...patch } : l));
|
||||||
|
const normalized = working.filter((l) => l.field);
|
||||||
|
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLevelRow = () => {
|
||||||
|
commitLevels(
|
||||||
|
[...groupByLevels, { field: '', direction: 'asc' }],
|
||||||
|
activeViewId,
|
||||||
|
onGroupByLevelsChange,
|
||||||
|
onUpdateViewGrouping,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLevel = (idx: number) => {
|
||||||
|
const working = levelsForUi.filter((_, i) => i !== idx);
|
||||||
|
const normalized = working.filter((l) => l.field);
|
||||||
|
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
groupByLevels.length === 0
|
||||||
|
? t('Keine')
|
||||||
|
: groupByLevels
|
||||||
|
.filter((l) => l.field)
|
||||||
|
.map((l) => columnOptions.find((c) => c.key === l.field)?.label ?? l.field)
|
||||||
|
.join(' › ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div ref={wrapRef} className={styles.popoverAnchor}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
|
||||||
|
onClick={() => setGroupMenuOpen((o) => !o)}
|
||||||
|
aria-expanded={groupMenuOpen}
|
||||||
|
aria-label={t('Gruppieren')}
|
||||||
|
title={t('Gruppieren')}
|
||||||
|
>
|
||||||
|
<FaLayerGroup className={styles.groupIcon} aria-hidden />
|
||||||
|
</button>
|
||||||
|
{groupMenuOpen && (
|
||||||
|
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
|
||||||
|
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
|
||||||
|
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
|
||||||
|
<div className={styles.levelList}>
|
||||||
|
{levelsForUi.map((level, idx) => (
|
||||||
|
<div key={idx} className={styles.levelRow}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
aria-label={t('Spalte')}
|
||||||
|
value={level.field}
|
||||||
|
onChange={(e) => updateLevel(idx, { field: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">{t('Spalte wählen')}</option>
|
||||||
|
{columnsForRow(idx, level.field).map((c) => (
|
||||||
|
<option key={c.key} value={c.key}>
|
||||||
|
{c.label || c.key}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.selectOrder}
|
||||||
|
aria-label={t('Sortierung')}
|
||||||
|
value={level.direction}
|
||||||
|
disabled={!level.field}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLevel(idx, { direction: e.target.value === 'desc' ? 'desc' : 'asc' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="asc">{t('Aufsteigend')}</option>
|
||||||
|
<option value="desc">{t('Absteigend')}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
title={t('Ebene entfernen')}
|
||||||
|
aria-label={t('Ebene entfernen')}
|
||||||
|
disabled={levelsForUi.length <= 1 && !level.field}
|
||||||
|
onClick={() => removeLevel(idx)}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={styles.addLevelBtn} onClick={addLevelRow}>
|
||||||
|
{t('+ Weitere Ebene')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.activeSummary} title={summary}>
|
||||||
|
{groupByLevels.filter((l) => l.field).length === 0
|
||||||
|
? t('Nicht gruppiert')
|
||||||
|
: `${t('Aktiv')}: ${summary}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={styles.viewBlock}>
|
||||||
|
<span className={styles.viewLabel}>{t('Ansicht')}</span>
|
||||||
|
<select
|
||||||
|
className={styles.viewSelect}
|
||||||
|
value={activeViewKey ?? ''}
|
||||||
|
disabled={loadingViews}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onSelectView(v === '' ? null : v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t('Standard')}</option>
|
||||||
|
{views.map((v) => (
|
||||||
|
<option key={v.id} value={v.viewKey}>
|
||||||
|
{v.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnGhost}
|
||||||
|
disabled={loadingViews || overwriteSaving}
|
||||||
|
title={
|
||||||
|
activeViewId
|
||||||
|
? t('Aktuelle Ansicht mit Filter, Sortierung und Gruppierung überschreiben')
|
||||||
|
: t('Neue Ansicht speichern')
|
||||||
|
}
|
||||||
|
onClick={() => void _onClickSave()}
|
||||||
|
>
|
||||||
|
{overwriteSaving ? t('Wird gespeichert…') : t('Speichern…')}
|
||||||
|
</button>
|
||||||
|
{activeViewId && onDeleteView && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnDangerGhost}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('Diese Ansicht wirklich löschen?'))) {
|
||||||
|
void Promise.resolve(onDeleteView(activeViewId)).then(() => onReloadViews());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Löschen')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveOpen && (
|
||||||
|
<div
|
||||||
|
className={styles.modalBackdrop}
|
||||||
|
role="presentation"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setSaveOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.modal} role="dialog" aria-labelledby="new-view-title" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 id="new-view-title">{t('Neue Ansicht')}</h3>
|
||||||
|
<p className={styles.modalHint}>{t('Übernimmt Filter, Sortierung und Gruppierung.')}</p>
|
||||||
|
<div className={styles.modalField}>
|
||||||
|
<label htmlFor="nv-name">{t('Anzeigename')}</label>
|
||||||
|
<input
|
||||||
|
id="nv-name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t('z. B. Nach Status')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button type="button" className={styles.btnGhost} onClick={() => setSaveOpen(false)}>
|
||||||
|
{t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
disabled={saving || !newName.trim()}
|
||||||
|
onClick={() => void _saveNew()}
|
||||||
|
>
|
||||||
|
{saving ? t('Speichern…') : t('Erstellen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar';
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
fetchBalances,
|
fetchBalances,
|
||||||
fetchBalanceForMandate,
|
fetchBalanceForMandate,
|
||||||
fetchTransactions,
|
fetchTransactions,
|
||||||
|
fetchTransactionsPaginated,
|
||||||
fetchStatistics,
|
fetchStatistics,
|
||||||
fetchAllowedProviders,
|
fetchAllowedProviders,
|
||||||
fetchSettingsAdmin,
|
fetchSettingsAdmin,
|
||||||
|
|
@ -31,7 +32,9 @@ import {
|
||||||
type MandateUserSummary,
|
type MandateUserSummary,
|
||||||
type StatisticsRangeRequest,
|
type StatisticsRangeRequest,
|
||||||
type BillingBucketSize,
|
type BillingBucketSize,
|
||||||
|
type BillingTransactionsPaginationParams,
|
||||||
} from '../api/billingApi';
|
} from '../api/billingApi';
|
||||||
|
import type { GroupLayout } from '../api/connectionApi';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -47,7 +50,7 @@ export type {
|
||||||
BillingBucketSize,
|
BillingBucketSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
export type { TransactionType, ReferenceType, BillingTransactionsPaginationParams } from '../api/billingApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for user billing operations
|
* Hook for user billing operations
|
||||||
|
|
@ -55,6 +58,17 @@ export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||||
export function useBilling() {
|
export function useBilling() {
|
||||||
const [balances, setBalances] = useState<BillingBalance[]>([]);
|
const [balances, setBalances] = useState<BillingBalance[]>([]);
|
||||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
||||||
|
const [transactionsPagination, setTransactionsPagination] = useState<{
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
|
||||||
|
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
|
||||||
|
viewKey?: string;
|
||||||
|
displayName?: string;
|
||||||
|
} | null>(null);
|
||||||
const [statistics, setStatistics] = useState<UsageReport | null>(null);
|
const [statistics, setStatistics] = useState<UsageReport | null>(null);
|
||||||
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
||||||
const { request, isLoading: loading, error } = useApiRequest();
|
const { request, isLoading: loading, error } = useApiRequest();
|
||||||
|
|
@ -87,14 +101,38 @@ export function useBilling() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchTransactions(request, limit, offset);
|
const data = await fetchTransactions(request, limit, offset);
|
||||||
setTransactions(Array.isArray(data) ? data : []);
|
setTransactions(Array.isArray(data) ? data : []);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading transactions:', err);
|
console.error('Error loading transactions:', err);
|
||||||
setTransactions([]);
|
setTransactions([]);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
const refetchTransactions = useCallback(async (params?: BillingTransactionsPaginationParams) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchTransactionsPaginated(request, params);
|
||||||
|
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||||
|
setTransactionsPagination(data.pagination ?? null);
|
||||||
|
setTransactionsGroupLayout(data.groupLayout ?? null);
|
||||||
|
setTransactionsAppliedView(data.appliedView ?? null);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading transactions:', err);
|
||||||
|
setTransactions([]);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchStatistics(request, range);
|
const data = await fetchStatistics(request, range);
|
||||||
|
|
@ -129,6 +167,9 @@ export function useBilling() {
|
||||||
return {
|
return {
|
||||||
balances,
|
balances,
|
||||||
transactions,
|
transactions,
|
||||||
|
transactionsPagination,
|
||||||
|
transactionsGroupLayout,
|
||||||
|
transactionsAppliedView,
|
||||||
statistics,
|
statistics,
|
||||||
allowedProviders,
|
allowedProviders,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -136,6 +177,7 @@ export function useBilling() {
|
||||||
loadBalances,
|
loadBalances,
|
||||||
loadBalanceForMandate,
|
loadBalanceForMandate,
|
||||||
loadTransactions,
|
loadTransactions,
|
||||||
|
refetchTransactions,
|
||||||
loadStatistics,
|
loadStatistics,
|
||||||
loadAllowedProviders,
|
loadAllowedProviders,
|
||||||
refetch: loadBalances,
|
refetch: loadBalances,
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,13 @@ import {
|
||||||
type AttributeDefinition,
|
type AttributeDefinition,
|
||||||
type PaginationParams,
|
type PaginationParams,
|
||||||
type CreateConnectionData,
|
type CreateConnectionData,
|
||||||
type ConnectResponse
|
type ConnectResponse,
|
||||||
|
type PaginatedResponse,
|
||||||
|
type GroupLayout,
|
||||||
} from '../api/connectionApi';
|
} from '../api/connectionApi';
|
||||||
|
|
||||||
// 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() {
|
||||||
|
|
@ -35,7 +36,8 @@ export function useConnections() {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [groupTree, setGroupTree] = useState<import('../api/connectionApi').TableGroupNode[]>([]);
|
const [groupLayout, setGroupLayout] = useState<GroupLayout | null>(null);
|
||||||
|
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||||
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>();
|
||||||
|
|
@ -91,6 +93,69 @@ export function useConnections() {
|
||||||
}
|
}
|
||||||
}, [checkPermission]);
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const { data } = await api.get('/api/connections/', {
|
||||||
|
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: any,
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const { data } = await api.get('/api/connections/', {
|
||||||
|
params: { pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch connections with pagination support
|
// Fetch connections with pagination support
|
||||||
const fetchConnections = useCallback(async (params?: PaginationParams): Promise<Connection[]> => {
|
const fetchConnections = useCallback(async (params?: PaginationParams): Promise<Connection[]> => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -103,14 +168,15 @@ export function useConnections() {
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
setPagination(data.pagination);
|
setPagination(data.pagination);
|
||||||
}
|
}
|
||||||
if (Array.isArray(data.groupTree)) {
|
setGroupLayout((data as PaginatedResponse<Connection>).groupLayout ?? null);
|
||||||
setGroupTree(data.groupTree);
|
setAppliedView((data as PaginatedResponse<Connection>).appliedView ?? null);
|
||||||
}
|
|
||||||
} 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 : [];
|
||||||
setConnections(items);
|
setConnections(items);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(data) ? data : (data?.items || []);
|
return Array.isArray(data) ? data : (data?.items || []);
|
||||||
|
|
@ -118,6 +184,8 @@ export function useConnections() {
|
||||||
console.error('Error fetching connections:', error);
|
console.error('Error fetching connections:', error);
|
||||||
setConnections([]);
|
setConnections([]);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
@ -824,6 +892,8 @@ export function useConnections() {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
ensureAttributesLoaded,
|
ensureAttributesLoaded,
|
||||||
fetchAttributes,
|
fetchAttributes,
|
||||||
|
|
@ -832,7 +902,8 @@ export function useConnections() {
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
fetchConnectionById,
|
fetchConnectionById,
|
||||||
groupTree,
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
moveFiles as moveFilesApi,
|
moveFiles as moveFilesApi,
|
||||||
type FolderInfo,
|
type FolderInfo,
|
||||||
} from '../api/fileApi';
|
} from '../api/fileApi';
|
||||||
import type { TableGroupNode } from '../api/connectionApi';
|
|
||||||
|
|
||||||
export interface FilePreviewResult {
|
export interface FilePreviewResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -69,6 +68,7 @@ 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;
|
||||||
|
viewKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files list hook
|
// Files list hook
|
||||||
|
|
@ -82,7 +82,8 @@ export function useUserFiles() {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
|
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
|
||||||
|
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||||
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
|
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
|
||||||
const { checkPermission } = usePermissions();
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
|
@ -140,6 +141,69 @@ export function useUserFiles() {
|
||||||
}
|
}
|
||||||
}, [checkPermission]);
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const { data } = await api.get('/api/files/list', {
|
||||||
|
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: PaginationParams & { page: number; pageSize: number },
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const { data } = await api.get('/api/files/list', {
|
||||||
|
params: { pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchFiles = useCallback(async (params?: PaginationParams) => {
|
const fetchFiles = useCallback(async (params?: PaginationParams) => {
|
||||||
// Check if user is authenticated before fetching files
|
// Check if user is authenticated before fetching files
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
|
|
@ -182,28 +246,20 @@ export function useUserFiles() {
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
setPagination(data.pagination);
|
setPagination(data.pagination);
|
||||||
}
|
}
|
||||||
if (Array.isArray((data as any).groupTree)) {
|
setGroupLayout((data as any).groupLayout ?? null);
|
||||||
setGroupTree((data as any).groupTree);
|
setAppliedView((data as any).appliedView ?? null);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Handle non-paginated response (backward compatibility)
|
|
||||||
console.log('📋 Processing non-paginated response:', {
|
|
||||||
isArray: Array.isArray(data),
|
|
||||||
dataLength: Array.isArray(data) ? data.length : 'not an array',
|
|
||||||
firstItemRaw: Array.isArray(data) && data.length > 0 ? data[0] : null,
|
|
||||||
allDataRaw: data
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use backend data directly - no mapping needed, just like prompts
|
|
||||||
const items = Array.isArray(data) ? data : [];
|
const items = Array.isArray(data) ? data : [];
|
||||||
console.log('📊 Final files array (non-paginated, using backend data directly):', items);
|
|
||||||
setFiles(items);
|
setFiles(items);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Error is already handled by useApiRequest
|
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
|
@ -338,10 +394,13 @@ export function useUserFiles() {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
groupTree,
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
fetchFileById,
|
fetchFileById,
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
ensureAttributesLoaded
|
ensureAttributesLoaded,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ 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 };
|
||||||
|
|
@ -35,7 +34,8 @@ export function usePrompts() {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
|
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
|
||||||
|
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||||
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
|
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
|
||||||
const { checkPermission } = usePermissions();
|
const { checkPermission } = usePermissions();
|
||||||
|
|
||||||
|
|
@ -90,6 +90,69 @@ export function usePrompts() {
|
||||||
}
|
}
|
||||||
}, [checkPermission]);
|
}, [checkPermission]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const { data } = await api.get('/api/prompts', {
|
||||||
|
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: any,
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const { data } = await api.get('/api/prompts', {
|
||||||
|
params: { pagination: JSON.stringify(pObj) },
|
||||||
|
});
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchPrompts = useCallback(async (params?: PaginationParams) => {
|
const fetchPrompts = useCallback(async (params?: PaginationParams) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchPromptsApi(request, params);
|
const data = await fetchPromptsApi(request, params);
|
||||||
|
|
@ -101,19 +164,22 @@ export function usePrompts() {
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
setPagination(data.pagination);
|
setPagination(data.pagination);
|
||||||
}
|
}
|
||||||
if (Array.isArray((data as any).groupTree)) {
|
setGroupLayout(data.groupLayout ?? null);
|
||||||
setGroupTree((data as any).groupTree);
|
setAppliedView(data.appliedView ?? null);
|
||||||
}
|
|
||||||
} 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 : [];
|
||||||
setPrompts(items);
|
setPrompts(items);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Error is already handled by useApiRequest
|
// Error is already handled by useApiRequest
|
||||||
setPrompts([]);
|
setPrompts([]);
|
||||||
setPagination(null);
|
setPagination(null);
|
||||||
|
setGroupLayout(null);
|
||||||
|
setAppliedView(null);
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
|
@ -459,11 +525,14 @@ export function usePrompts() {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
groupTree,
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
fetchPromptById,
|
fetchPromptById,
|
||||||
generateEditFieldsFromAttributes,
|
generateEditFieldsFromAttributes,
|
||||||
generateCreateFieldsFromAttributes,
|
generateCreateFieldsFromAttributes,
|
||||||
ensureAttributesLoaded
|
ensureAttributesLoaded,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,15 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
fetchConnectionById,
|
fetchConnectionById,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
deleteConnection,
|
deleteConnection,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
createConnectionAndAuth,
|
createConnectionAndAuth,
|
||||||
|
|
@ -44,7 +48,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
refreshMicrosoftToken,
|
refreshMicrosoftToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
groupTree,
|
|
||||||
} = useConnections();
|
} = useConnections();
|
||||||
|
|
||||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
||||||
|
|
@ -415,6 +418,8 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
data={connections}
|
data={connections}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/connections/"
|
apiEndpoint="/api/connections/"
|
||||||
|
tableContextKey="connections"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -467,12 +472,14 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
refetch,
|
refetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
handleDelete: deleteConnection,
|
handleDelete: deleteConnection,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
groupTree,
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
}}
|
}}
|
||||||
groupingConfig={{ contextKey: 'connections', enabled: true }}
|
|
||||||
emptyMessage={t('Keine Verbindungen gefunden')}
|
emptyMessage={t('Keine Verbindungen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect, useRef, useCallback, type PointerEvent as RPointerEvent } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback, type PointerEvent as RPointerEvent } from 'react';
|
||||||
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
import { useUserFiles, useFileOperations, type PaginationParams } from '../../hooks/useFiles';
|
||||||
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 { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree';
|
import { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree';
|
||||||
|
|
@ -50,8 +50,12 @@ export const FilesPage: React.FC = () => {
|
||||||
error,
|
error,
|
||||||
refetch: tableRefetch,
|
refetch: tableRefetch,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
fetchFileById,
|
fetchFileById,
|
||||||
updateFileOptimistically,
|
updateFileOptimistically,
|
||||||
|
fetchGroupSectionSummaries: fetchGroupSectionSummariesFromHook,
|
||||||
|
refetchForSection: refetchForSectionFromHook,
|
||||||
} = useUserFiles();
|
} = useUserFiles();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -108,6 +112,39 @@ export const FilesPage: React.FC = () => {
|
||||||
await tableRefetch(nextParams);
|
await tableRefetch(nextParams);
|
||||||
}, [tableRefetch, selectedFolderId, viewMode]);
|
}, [tableRefetch, selectedFolderId, viewMode]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const filters = { ...(base.filters || {}) };
|
||||||
|
if (viewMode === 'folder' && selectedFolderId) {
|
||||||
|
filters.folderId = selectedFolderId;
|
||||||
|
}
|
||||||
|
return fetchGroupSectionSummariesFromHook({ ...base, filters });
|
||||||
|
},
|
||||||
|
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: PaginationParams & { page: number; pageSize: number },
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const merged = { ...(parentColumnFilters || {}) };
|
||||||
|
if (viewMode === 'folder' && selectedFolderId) {
|
||||||
|
merged.folderId = selectedFolderId;
|
||||||
|
}
|
||||||
|
return refetchForSectionFromHook(paginationParams, sectionFilter, merged);
|
||||||
|
},
|
||||||
|
[refetchForSectionFromHook, viewMode, selectedFolderId],
|
||||||
|
);
|
||||||
|
|
||||||
const _refreshAll = useCallback(async () => {
|
const _refreshAll = useCallback(async () => {
|
||||||
await _tableRefetch({ page: 1, pageSize: 25 });
|
await _tableRefetch({ page: 1, pageSize: 25 });
|
||||||
setTreeKey(k => k + 1);
|
setTreeKey(k => k + 1);
|
||||||
|
|
@ -333,6 +370,7 @@ export const FilesPage: React.FC = () => {
|
||||||
ownership="own"
|
ownership="own"
|
||||||
title={t('Eigene')}
|
title={t('Eigene')}
|
||||||
showFilter={true}
|
showFilter={true}
|
||||||
|
allowCreateFolder={canCreate}
|
||||||
onNodeClick={_handleTreeNodeClick}
|
onNodeClick={_handleTreeNodeClick}
|
||||||
onRefresh={() => _tableRefetch()}
|
onRefresh={() => _tableRefetch()}
|
||||||
/>
|
/>
|
||||||
|
|
@ -409,6 +447,8 @@ export const FilesPage: React.FC = () => {
|
||||||
data={tableFiles || []}
|
data={tableFiles || []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/files/list"
|
apiEndpoint="/api/files/list"
|
||||||
|
tableContextKey="files/list"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={tableLoading}
|
loading={tableLoading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -459,11 +499,15 @@ export const FilesPage: React.FC = () => {
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: _tableRefetch,
|
refetch: _tableRefetch,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
permissions,
|
permissions,
|
||||||
handleDelete: handleFileDelete,
|
handleDelete: handleFileDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically: updateFileOptimistically,
|
updateOptimistically: updateFileOptimistically,
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Dateien gefunden')}
|
emptyMessage={t('Keine Dateien gefunden')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,15 @@ export const PromptsPage: React.FC = () => {
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
groupTree,
|
|
||||||
fetchPromptById,
|
fetchPromptById,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
} = usePrompts();
|
} = usePrompts();
|
||||||
|
|
||||||
// Operations hook
|
// Operations hook
|
||||||
|
|
@ -205,6 +208,8 @@ export const PromptsPage: React.FC = () => {
|
||||||
data={prompts}
|
data={prompts}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/prompts"
|
apiEndpoint="/api/prompts"
|
||||||
|
tableContextKey="prompts"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -234,12 +239,14 @@ export const PromptsPage: React.FC = () => {
|
||||||
refetch: _tableRefetch,
|
refetch: _tableRefetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
|
groupLayout,
|
||||||
|
appliedView,
|
||||||
handleDelete: handlePromptDelete,
|
handleDelete: handlePromptDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
groupTree,
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
}}
|
}}
|
||||||
groupingConfig={{ contextKey: 'prompts', enabled: true }}
|
|
||||||
emptyMessage={t('Keine Prompts gefunden')}
|
emptyMessage={t('Keine Prompts gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,36 @@
|
||||||
|
|
||||||
.billingDashboard {
|
.billingDashboard {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
height: 100%;
|
/* Fill MainLayout outletShell (flex column); height:100% alone does not grow the flex item */
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flex host for tab panels so the transactions tab can grow to the viewport */
|
||||||
|
.billingTabBody {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Overview/Diagramme: scroll; Transaktionen: single flex child fills height */
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transactions tab: consume remaining viewport so nested tables can flex */
|
||||||
|
.transactionsTabLayout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import type { AttributeDefinition } from '../../api/attributesApi';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
||||||
import { UserTransaction } from '../../api/billingApi';
|
import { UserTransaction } from '../../api/billingApi';
|
||||||
|
import type { GroupLayout } from '../../api/connectionApi';
|
||||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import {
|
import {
|
||||||
|
|
@ -343,6 +344,11 @@ export const BillingDataView: React.FC = () => {
|
||||||
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
||||||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||||
|
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
|
||||||
|
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
|
||||||
|
viewKey?: string;
|
||||||
|
displayName?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'BillingTransactionView')
|
fetchAttributes(request, 'BillingTransactionView')
|
||||||
|
|
@ -479,6 +485,8 @@ export const BillingDataView: React.FC = () => {
|
||||||
if (paginationParams.sort) pObj.sort = paginationParams.sort;
|
if (paginationParams.sort) pObj.sort = paginationParams.sort;
|
||||||
if (paginationParams.filters) pObj.filters = paginationParams.filters;
|
if (paginationParams.filters) pObj.filters = paginationParams.filters;
|
||||||
if (paginationParams.search) pObj.search = paginationParams.search;
|
if (paginationParams.search) pObj.search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) pObj.viewKey = paginationParams.viewKey;
|
||||||
|
if (paginationParams.groupByLevels !== undefined) pObj.groupByLevels = paginationParams.groupByLevels;
|
||||||
if (Object.keys(pObj).length > 0) {
|
if (Object.keys(pObj).length > 0) {
|
||||||
params.pagination = JSON.stringify(pObj);
|
params.pagination = JSON.stringify(pObj);
|
||||||
}
|
}
|
||||||
|
|
@ -489,20 +497,96 @@ export const BillingDataView: React.FC = () => {
|
||||||
|
|
||||||
if (data && typeof data === 'object' && 'items' in data) {
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
setTransactions(Array.isArray(data.items) ? data.items : []);
|
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||||
if (data.pagination) {
|
setTransactionsPagination(data.pagination ?? null);
|
||||||
setTransactionsPagination(data.pagination);
|
setTransactionsGroupLayout(data.groupLayout ?? null);
|
||||||
}
|
setTransactionsAppliedView(data.appliedView ?? null);
|
||||||
|
return data;
|
||||||
} else {
|
} else {
|
||||||
setTransactions(Array.isArray(data) ? data : []);
|
setTransactions(Array.isArray(data) ? data : []);
|
||||||
|
setTransactionsPagination(null);
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load transactions:', err);
|
console.error('Failed to load transactions:', err);
|
||||||
setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen'));
|
setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen'));
|
||||||
|
setTransactionsGroupLayout(null);
|
||||||
|
setTransactionsAppliedView(null);
|
||||||
} finally {
|
} finally {
|
||||||
setTransactionsLoading(false);
|
setTransactionsLoading(false);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}, [_scopeParams, t]);
|
}, [_scopeParams, t]);
|
||||||
|
|
||||||
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
|
async (base: {
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: string }>;
|
||||||
|
viewKey?: string | null;
|
||||||
|
groupField: string;
|
||||||
|
groupDirection?: 'asc' | 'desc';
|
||||||
|
}) => {
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
groupByLevels: [
|
||||||
|
{
|
||||||
|
field: base.groupField,
|
||||||
|
nullLabel: '—',
|
||||||
|
direction: base.groupDirection || 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (base.search) (pObj as { search?: string }).search = base.search;
|
||||||
|
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||||||
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
..._scopeParams,
|
||||||
|
mode: 'groupSummary',
|
||||||
|
pagination: JSON.stringify(pObj),
|
||||||
|
};
|
||||||
|
const { data } = await api.get('/api/billing/view/users/transactions', { params });
|
||||||
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
|
},
|
||||||
|
[_scopeParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchForSection = useCallback(
|
||||||
|
async (
|
||||||
|
paginationParams: any,
|
||||||
|
sectionFilter: Record<string, unknown>,
|
||||||
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const mergedFilters = {
|
||||||
|
...(parentColumnFilters || {}),
|
||||||
|
...(paginationParams.filters || {}),
|
||||||
|
...sectionFilter,
|
||||||
|
};
|
||||||
|
const pObj: Record<string, unknown> = {
|
||||||
|
page: paginationParams.page,
|
||||||
|
pageSize: paginationParams.pageSize,
|
||||||
|
filters: mergedFilters,
|
||||||
|
groupByLevels: [],
|
||||||
|
};
|
||||||
|
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||||||
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
..._scopeParams,
|
||||||
|
pagination: JSON.stringify(pObj),
|
||||||
|
};
|
||||||
|
const { data } = await api.get('/api/billing/view/users/transactions', { params });
|
||||||
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
}
|
||||||
|
return { items: [], pagination: null };
|
||||||
|
},
|
||||||
|
[_scopeParams],
|
||||||
|
);
|
||||||
|
|
||||||
const _fetchTransactionFilterValues = useCallback(async (
|
const _fetchTransactionFilterValues = useCallback(async (
|
||||||
columnKey: string,
|
columnKey: string,
|
||||||
crossFilters?: Record<string, any>,
|
crossFilters?: Record<string, any>,
|
||||||
|
|
@ -518,11 +602,28 @@ export const BillingDataView: React.FC = () => {
|
||||||
return Array.isArray(resp.data) ? resp.data : [];
|
return Array.isArray(resp.data) ? resp.data : [];
|
||||||
}, [_scopeParams]);
|
}, [_scopeParams]);
|
||||||
|
|
||||||
const transactionsHookData = useMemo(() => ({
|
const transactionsHookData = useMemo(
|
||||||
refetch: _loadTransactions,
|
() => ({
|
||||||
pagination: transactionsPagination || undefined,
|
refetch: _loadTransactions,
|
||||||
fetchFilterValues: _fetchTransactionFilterValues,
|
pagination: transactionsPagination || undefined,
|
||||||
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
|
groupLayout: transactionsGroupLayout ?? undefined,
|
||||||
|
appliedView: transactionsAppliedView ?? undefined,
|
||||||
|
fetchFilterValues: _fetchTransactionFilterValues,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
|
csvExportQueryParams: _scopeParams,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
_loadTransactions,
|
||||||
|
transactionsPagination,
|
||||||
|
transactionsGroupLayout,
|
||||||
|
transactionsAppliedView,
|
||||||
|
_fetchTransactionFilterValues,
|
||||||
|
fetchGroupSectionSummaries,
|
||||||
|
refetchForSection,
|
||||||
|
_scopeParams,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
|
const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
|
{ key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 },
|
||||||
|
|
@ -635,6 +736,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={styles.billingTabBody}>
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{/* Tab: Übersicht (KPI overview) */}
|
{/* Tab: Übersicht (KPI overview) */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
|
|
@ -722,7 +824,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
{/* Tab: Transaktionen */}
|
{/* Tab: Transaktionen */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{activeTab === 'transactions' && (
|
{activeTab === 'transactions' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '500px' }}>
|
<div className={styles.transactionsTabLayout}>
|
||||||
{transactionsError && (
|
{transactionsError && (
|
||||||
<div className={styles.errorMessage}>
|
<div className={styles.errorMessage}>
|
||||||
{transactionsError}
|
{transactionsError}
|
||||||
|
|
@ -734,6 +836,8 @@ export const BillingDataView: React.FC = () => {
|
||||||
data={transactions}
|
data={transactions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/billing/view/users/transactions"
|
apiEndpoint="/api/billing/view/users/transactions"
|
||||||
|
tableContextKey="billing/view/users/transactions"
|
||||||
|
tableGroupLayoutMode="sections"
|
||||||
loading={transactionsLoading}
|
loading={transactionsLoading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -742,12 +846,13 @@ export const BillingDataView: React.FC = () => {
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
emptyMessage={t('Keine Transaktionen vorhanden')}
|
emptyMessage={t('Keine Transaktionen vorhanden')}
|
||||||
onRefresh={_loadTransactions}
|
|
||||||
hookData={transactionsHookData}
|
hookData={transactionsHookData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,178 @@
|
||||||
/**
|
/**
|
||||||
* Billing Transactions Page
|
* Billing Transactions Page
|
||||||
*
|
*
|
||||||
* Zeigt die Transaktionshistorie für den Benutzer.
|
* Transaktionshistorie mit FormGeneratorTable (Suche, Filter, Sortierung, Ansichten, Gruppierung).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
|
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
|
||||||
import { BillingNav } from './BillingNav';
|
import { BillingNav } from './BillingNav';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
// ============================================================================
|
function typePillClass(type: string): string {
|
||||||
// TRANSACTION ROW COMPONENT
|
switch (type) {
|
||||||
// ============================================================================
|
case 'CREDIT':
|
||||||
|
return styles.credit;
|
||||||
interface TransactionRowProps {
|
case 'DEBIT':
|
||||||
transaction: BillingTransaction;
|
return styles.debit;
|
||||||
|
case 'ADJUSTMENT':
|
||||||
|
return styles.adjustment;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
function typeLabel(type: string, t: (k: string) => string): string {
|
||||||
const formatCurrency = (amount: number) => {
|
switch (type) {
|
||||||
return new Intl.NumberFormat('de-CH', {
|
case 'CREDIT':
|
||||||
style: 'currency',
|
return t('Gutschrift');
|
||||||
currency: 'CHF'
|
case 'DEBIT':
|
||||||
}).format(amount);
|
return t('Belastung');
|
||||||
};
|
case 'ADJUSTMENT':
|
||||||
|
return t('Korrektur');
|
||||||
const formatDate = (dateString?: string) => {
|
default:
|
||||||
if (!dateString) return '-';
|
return type;
|
||||||
return new Date(dateString).toLocaleString('de-CH', {
|
}
|
||||||
year: 'numeric',
|
}
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeClass = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'CREDIT': return styles.credit;
|
|
||||||
case 'DEBIT': return styles.debit;
|
|
||||||
case 'ADJUSTMENT': return styles.adjustment;
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'CREDIT': return 'Gutschrift';
|
|
||||||
case 'DEBIT': return 'Belastung';
|
|
||||||
case 'ADJUSTMENT': return 'Korrektur';
|
|
||||||
default: return type;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td>{formatDate(transaction.sysCreatedAt)}</td>
|
|
||||||
<td>{transaction.mandateName || '-'}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
|
||||||
{getTypeLabel(transaction.transactionType)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{transaction.description}</td>
|
|
||||||
<td>{transaction.aicoreProvider || '-'}</td>
|
|
||||||
<td>{transaction.aicoreModel || '-'}</td>
|
|
||||||
<td>{transaction.featureCode || '-'}</td>
|
|
||||||
<td style={{ textAlign: 'right' }}>
|
|
||||||
{transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MAIN COMPONENT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const BillingTransactions: React.FC = () => {
|
export const BillingTransactions: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { transactions, loading, loadTransactions } = useBilling();
|
const {
|
||||||
const [limit, setLimit] = useState(50);
|
transactions,
|
||||||
|
loading,
|
||||||
useEffect(() => {
|
refetchTransactions,
|
||||||
loadTransactions(limit);
|
transactionsPagination,
|
||||||
}, [limit, loadTransactions]);
|
transactionsGroupLayout,
|
||||||
|
transactionsAppliedView,
|
||||||
const handleLoadMore = () => {
|
} = useBilling();
|
||||||
setLimit(prev => prev + 50);
|
|
||||||
};
|
const columns = useMemo((): ColumnConfig[] => {
|
||||||
|
const fmtChf = (amount: number) =>
|
||||||
|
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'sysCreatedAt',
|
||||||
|
label: t('Datum'),
|
||||||
|
type: 'date',
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
searchable: true,
|
||||||
|
width: 170,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mandateName',
|
||||||
|
label: t('Mandant'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transactionType',
|
||||||
|
label: t('Typ'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 130,
|
||||||
|
formatter: (_v, row: BillingTransaction) => (
|
||||||
|
<span className={`${styles.transactionType} ${typePillClass(row.transactionType)}`}>
|
||||||
|
{typeLabel(row.transactionType, t)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
label: t('Beschreibung'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aicoreProvider',
|
||||||
|
label: t('Anbieter'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'aicoreModel',
|
||||||
|
label: t('Modell'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'featureCode',
|
||||||
|
label: t('Feature'),
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
label: t('Betrag'),
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
width: 120,
|
||||||
|
formatter: (v, row: BillingTransaction) => {
|
||||||
|
const n = Number(v);
|
||||||
|
const abs = fmtChf(Math.abs(n));
|
||||||
|
const prefix = row.transactionType === 'DEBIT' ? '-' : '+';
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'block', textAlign: 'right' }}>
|
||||||
|
{prefix}
|
||||||
|
{abs}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={styles.billingDashboard}>
|
||||||
<header className={styles.pageHeader}>
|
<header className={styles.pageHeader}>
|
||||||
<h1>{t('Transaktionen')}</h1>
|
<h1>{t('Transaktionen')}</h1>
|
||||||
<p className={styles.subtitle}>{t('Übersicht aller Kontobewegungen')}</p>
|
<p className={styles.subtitle}>{t('Übersicht aller Kontobewegungen')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<BillingNav />
|
<BillingNav />
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
{loading && transactions.length === 0 ? (
|
<FormGeneratorTable<BillingTransaction>
|
||||||
<div className={styles.loadingPlaceholder}>{t('Transaktionen laden')}</div>
|
data={transactions}
|
||||||
) : transactions.length === 0 ? (
|
columns={columns}
|
||||||
<div className={styles.noData}>{t('Keine Transaktionen vorhanden')}</div>
|
apiEndpoint="/api/billing/transactions"
|
||||||
) : (
|
tableContextKey="billing/transactions"
|
||||||
<>
|
loading={loading}
|
||||||
<div style={{ overflowX: 'auto' }}>
|
pagination={true}
|
||||||
<table className={styles.transactionsTable}>
|
pageSize={25}
|
||||||
<thead>
|
searchable={true}
|
||||||
<tr>
|
filterable={true}
|
||||||
<th>Datum</th>
|
sortable={true}
|
||||||
<th>{t('Mandant')}</th>
|
selectable={false}
|
||||||
<th>Typ</th>
|
hookData={{
|
||||||
<th>{t('Beschreibung')}</th>
|
refetch: refetchTransactions,
|
||||||
<th>Anbieter</th>
|
pagination: transactionsPagination ?? undefined,
|
||||||
<th>Modell</th>
|
groupLayout: transactionsGroupLayout ?? undefined,
|
||||||
<th>Feature</th>
|
appliedView: transactionsAppliedView ?? undefined,
|
||||||
<th style={{ textAlign: 'right' }}>{t('Betrag')}</th>
|
}}
|
||||||
</tr>
|
emptyMessage={t('Keine Transaktionen vorhanden')}
|
||||||
</thead>
|
/>
|
||||||
<tbody>
|
|
||||||
{transactions.map((transaction) => (
|
|
||||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{transactions.length >= limit && (
|
|
||||||
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
|
|
||||||
<button
|
|
||||||
className={`${styles.button} ${styles.buttonSecondary}`}
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? t('Laden') : t('Mehr laden')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</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' }}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ 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';
|
||||||
|
|
@ -83,8 +81,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
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 workspaceInputRef = useRef<WorkspaceInputHandle>(null);
|
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);
|
||||||
|
|
@ -115,27 +111,6 @@ 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');
|
||||||
|
|
@ -153,20 +128,15 @@ 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[]) => {
|
const _resolveTreeItemsToFileIds = useCallback(async (items: TreeItemDrop[]) => {
|
||||||
let tree = filesListGroupTree;
|
|
||||||
if (items.some(i => i.type === 'group')) {
|
|
||||||
tree = await _pullFilesGroupTree();
|
|
||||||
}
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
if (it.type === 'group') {
|
// Group drops are no longer supported — groups are now presentation-only (view-based)
|
||||||
out.push(...collectGroupItemIds(tree, it.id));
|
if (it.type !== 'group') {
|
||||||
} else {
|
|
||||||
out.push(it.id);
|
out.push(it.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...new Set(out)];
|
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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue