Merge branch 'int' into feat/demo-system-readieness

This commit is contained in:
Patrick Motsch 2026-05-08 00:28:53 +02:00 committed by GitHub
commit bb9fd56bc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3149 additions and 1194 deletions

View file

@ -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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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

View file

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

View file

@ -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 '*';
} }

View file

@ -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

View file

@ -4,7 +4,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css'; import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button'; import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io"; import { IoIosRefresh } from "react-icons/io";
import { FaTrash, FaDownload, 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}

View file

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

View file

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

View file

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

View file

@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -108,7 +108,7 @@ export function createFolderFileProvider(): TreeNodeProvider {
return nodes; return nodes;
}, },
canCreate() { canCreate(_parentId: string | null) {
return true; return true;
}, },

View file

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

View file

@ -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';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

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

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

View file

@ -0,0 +1 @@
export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar';

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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')}
/> />

View file

@ -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>

View file

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

View file

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

View file

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

View file

@ -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' }}

View file

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