fixed formgenerator , trustee, sort and filter

This commit is contained in:
ValueOn AG 2026-04-14 00:15:51 +02:00
parent 5805c547eb
commit b76947d613
27 changed files with 557 additions and 156 deletions

View file

@ -177,6 +177,9 @@ function App() {
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
<Route path="settings" element={<FeatureViewPage view="settings" />} />
{/* Neutralization Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />
{/* CommCoach Feature Views */}
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />

View file

@ -390,6 +390,17 @@ export async function deleteWorkflow(
});
}
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
export async function deleteSystemWorkflow(
request: ApiRequestFunction,
workflowId: string,
): Promise<void> {
await request({
url: `/api/system/workflow-runs/workflows/${workflowId}`,
method: 'delete',
});
}
export interface Automation2Run {
id: string;
workflowId: string;

View file

@ -40,11 +40,11 @@ export interface FormGeneratorControlsProps {
onDeleteSingle?: () => void;
onDeleteMultiple?: () => void;
// Optional batch actions (e.g. "Sync to Accounting") shown when items are selected
batchActions?: {
label: string;
onClick: () => void | Promise<void>;
loading?: boolean;
disabled?: boolean;
icon?: IconType;
}[];
@ -71,9 +71,12 @@ export interface FormGeneratorControlsProps {
onPageSizeChange?: (pageSize: number) => void;
supportsBackendPagination?: boolean;
hookData?: any;
// CSV Export
onCsvExport?: () => void;
csvExporting?: boolean;
totalFilteredItems?: number;
onSelectAllFiltered?: () => void;
selectAllFilteredActive?: boolean;
selectAllFilteredLoading?: boolean;
}
export function FormGeneratorControls({
@ -102,7 +105,11 @@ export function FormGeneratorControls({
supportsBackendPagination = false,
hookData,
onCsvExport,
csvExporting = false
csvExporting = false,
totalFilteredItems,
onSelectAllFiltered,
selectAllFilteredActive = false,
selectAllFilteredLoading = false,
}: FormGeneratorControlsProps) {
const { t } = useLanguage();
@ -143,11 +150,32 @@ export function FormGeneratorControls({
variant="secondary"
size="sm"
icon={action.icon}
disabled={action.loading}
disabled={action.loading || action.disabled}
>
{action.loading ? t('Laden...') : action.label}
</Button>
))}
{onSelectAllFiltered && totalFilteredItems !== undefined && totalFilteredItems > selectedCount && !selectAllFilteredActive && (
<Button
onClick={onSelectAllFiltered}
variant="ghost"
size="sm"
disabled={selectAllFilteredLoading}
>
{selectAllFilteredLoading
? t('Laden...')
: t('Alle {count} Einträge auswählen', { count: totalFilteredItems.toString() })}
</Button>
)}
{selectAllFilteredActive && (
<Button
onClick={onSelectAllFiltered}
variant="ghost"
size="sm"
>
{t('Auswahl aufheben')}
</Button>
)}
</div>
)}

View file

@ -18,7 +18,7 @@
* fetchFilterValues, // (columnKey: string) => Promise<string[]> (Optional)
* // If provided, called when a filter dropdown opens.
* // If NOT provided but apiEndpoint is set, the table
* // auto-fetches from `{apiEndpoint}/filter-values?column=xxx`.
* // auto-fetches from `{apiEndpoint}?mode=filterValues&column=xxx`.
* }}
*
* Without hookData.refetch, interactive controls (sort, filter, search,
@ -28,7 +28,7 @@
* When a filterable column's dropdown opens, distinct values are loaded from:
* 1. column.filterOptions (static enum used as-is, no backend call)
* 2. hookData.fetchFilterValues(columnKey) if provided
* 3. GET {apiEndpoint}/filter-values?column=xxx&pagination={currentFilters}
* 3. GET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}
* Cross-filtering is supported: changing a filter invalidates the cache,
* so re-opening another column's dropdown re-fetches with current filters.
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker.
@ -36,7 +36,7 @@
* BACKEND RESPONSE FORMAT (for refetch):
* { items: T[], pagination: PaginationMetadata | null }
*
* BACKEND RESPONSE FORMAT (for filter-values):
* BACKEND RESPONSE FORMAT (for mode=filterValues):
* string[]
*
* EXAMPLE (minimal integration):
@ -108,6 +108,7 @@ export interface ColumnConfig {
searchable?: boolean;
formatter?: (value: any, row: any) => React.ReactNode;
filterOptions?: string[]; // For enum/select filters
filterLabelResolver?: (value: string) => string; // Map filter value to display label in dropdown
cellClassName?: (value: any, row: any) => string; // For custom cell styling
fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/")
fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel")
@ -127,6 +128,7 @@ export interface FormGeneratorTableProps<T = any> {
showPageSizeSelector?: boolean;
onRowClick?: (row: T, index: number) => void;
onRowSelect?: (selectedRows: T[]) => void;
onSelectionChange?: (selectedIds: Set<string>) => void;
selectable?: boolean;
isRowSelectable?: (row: T) => boolean;
loading?: boolean;
@ -176,6 +178,7 @@ export interface FormGeneratorTableProps<T = any> {
onClick: (rows: T[]) => void | Promise<void>;
loading?: boolean;
icon?: IconType;
isApplicable?: (row: T) => boolean;
}[];
onRefresh?: () => void;
className?: string;
@ -193,6 +196,7 @@ export interface FormGeneratorTableProps<T = any> {
groupDefaultExpanded?: boolean;
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
initialSearchTerm?: string;
initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>;
rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
}
@ -217,32 +221,70 @@ function FilterValuesList({
resolveLabel?: (value: string) => string;
}) {
const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE);
const [searchTerm, setSearchTerm] = useState('');
const sentinelRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setDisplayCount(_FILTER_PAGE_SIZE);
setSearchTerm('');
}, [columnKey, allValues.length]);
useEffect(() => {
searchInputRef.current?.focus();
}, [columnKey]);
const filteredValues = useMemo(() => {
if (!searchTerm.trim()) return allValues;
const term = searchTerm.toLowerCase();
return allValues.filter(value => {
const label = resolveLabel ? resolveLabel(value) : value;
return label.toLowerCase().includes(term);
});
}, [allValues, searchTerm, resolveLabel]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || displayCount >= allValues.length) return;
if (!sentinel || displayCount >= filteredValues.length) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, allValues.length));
setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, filteredValues.length));
}
},
{ threshold: 0.1 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [displayCount, allValues.length]);
}, [displayCount, filteredValues.length]);
const visibleValues = allValues.slice(0, displayCount);
const visibleValues = filteredValues.slice(0, displayCount);
const showSearch = allValues.length > 10;
return (
<>
{showSearch && (
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
<input
ref={searchInputRef}
type="text"
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }}
placeholder="Filter..."
style={{
width: '100%',
padding: '3px 6px',
fontSize: '12px',
border: '1px solid var(--border-color, #ccc)',
borderRadius: '3px',
outline: 'none',
boxSizing: 'border-box',
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{visibleValues.map(value => {
const label = resolveLabel ? resolveLabel(value) : value;
return (
@ -256,9 +298,14 @@ function FilterValuesList({
</div>
);
})}
{displayCount < allValues.length && (
{displayCount < filteredValues.length && (
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} />
)}
{showSearch && searchTerm && filteredValues.length === 0 && (
<div style={{ padding: '6px 8px', fontSize: '12px', color: 'var(--text-secondary, #888)' }}>
Keine Treffer
</div>
)}
</>
);
}
@ -276,7 +323,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
showPageSizeSelector = true,
onRowClick,
onRowSelect,
selectable = true, // Default to true for selection functionality
onSelectionChange,
selectable = true,
isRowSelectable,
loading = false,
inlineEditable = true, // Enable inline editing by default
@ -300,6 +348,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
groupDefaultExpanded = true,
groupActions,
initialSearchTerm = '',
initialSort,
rowDraggable = false,
onRowDragStart,
}: FormGeneratorTableProps<T>) {
@ -341,12 +390,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [searchFocused, setSearchFocused] = useState(false);
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
// Multi-column sorting: array of sort configs in order of priority
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>([]);
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []);
const [filters, setFilters] = useState<Record<string, any>>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
// Actions column width - resizable, default based on number of buttons
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectAllFilteredActive, setSelectAllFilteredActive] = useState(false);
const [selectAllFilteredLoading, setSelectAllFilteredLoading] = useState(false);
const _idField = idField || 'id';
const _getRowId = useCallback((row: any): string => String(row?.[_idField] ?? ''), [_idField]);
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
@ -639,7 +692,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
let displayLabel = item.id; // Fallback to ID
// Use the EXPLICIT display field from Pydantic model (fkDisplayField)
if (displayField && item[displayField] !== undefined) {
if (displayField && item[displayField] != null && item[displayField] !== '') {
displayLabel = convertToDisplayString(item[displayField], currentLanguage);
} else {
// Fallback: if no displayField specified, try common fields
@ -862,8 +915,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Skip if column has static filterOptions (enum) those are used directly
if (column?.filterOptions && column.filterOptions.length > 0) return;
// FK columns: extract values from actual data instead of backend endpoint
if (column?.fkSource) return;
// FK columns with backend pagination: still fetch from backend (data is only one page)
// FK columns without backend pagination: skip (data is the full dataset, extracted below)
if (column?.fkSource && !supportsBackendPagination) return;
// Skip if already loaded or currently loading
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
@ -879,12 +933,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
});
values = await hookData.fetchFilterValues(columnKey, crossFilters);
} else if (apiEndpoint && supportsBackendPagination) {
const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint;
const params: Record<string, string> = { column: columnKey };
const params: Record<string, string> = { mode: 'filterValues', column: columnKey };
if (Object.keys(filters).length > 0) {
params.pagination = JSON.stringify({ filters });
}
const response = await api.get(`${endpoint}/filter-values`, { params });
const response = await api.get(apiEndpoint, { params });
values = Array.isArray(response.data) ? response.data : [];
} else {
values = [];
@ -913,8 +966,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return column.filterOptions;
}
// FK columns: extract distinct values from actual data (Excel autofilter style)
if (column?.fkSource) {
// FK columns without backend pagination: extract from full local data
if (column?.fkSource && !supportsBackendPagination) {
const seen = new Set<string>();
data.forEach(row => {
const val = row[columnKey];
@ -937,7 +990,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
console.warn(
`FormGeneratorTable: Column "${columnKey}" is filterable ` +
`but has no filterOptions, no hookData.fetchFilterValues, and no apiEndpoint. ` +
`Filter dropdown will be empty. Provide apiEndpoint (auto-fetches /filter-values) ` +
`Filter dropdown will be empty. Provide apiEndpoint (auto-fetches ?mode=filterValues) ` +
`or add filterOptions to the column config.`
);
}
@ -964,73 +1017,99 @@ export function FormGeneratorTable<T extends Record<string, any>>({
setOpenFilterColumn(prev => prev === columnKey ? null : columnKey);
}, []);
// Handle row selection
const handleRowSelect = (index: number) => {
const _notifySelection = useCallback((newIds: Set<string>) => {
setSelectedIds(newIds);
onSelectionChange?.(newIds);
if (onRowSelect) {
const rows = displayData.filter(row => newIds.has(_getRowId(row)));
onRowSelect(rows);
}
}, [displayData, onRowSelect, onSelectionChange, _getRowId]);
// Reset selection on filter/search/page/pageSize/sort changes
const paginationState = hookData?.pagination;
const currentPageFromHook = paginationState?.currentPage;
const currentPageSizeFromHook = paginationState?.pageSize;
const currentFiltersJson = JSON.stringify(filters);
const currentSortJson = JSON.stringify(sortConfigs);
useEffect(() => {
if (selectedIds.size > 0) {
_notifySelection(new Set());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPageFromHook, currentPageSizeFromHook, currentFiltersJson, searchTerm, currentSortJson]);
const handleRowSelect = (row: T) => {
if (!selectable) return;
const row = displayData[index];
if (isRowSelectable && !isRowSelectable(row)) return;
const newSelected = new Set(selectedRows);
if (newSelected.has(index)) {
newSelected.delete(index);
const rowId = _getRowId(row);
const newSelected = new Set(selectedIds);
if (newSelected.has(rowId)) {
newSelected.delete(rowId);
} else {
newSelected.add(index);
}
setSelectedRows(newSelected);
if (onRowSelect) {
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onRowSelect(selectedData);
newSelected.add(rowId);
}
_notifySelection(newSelected);
};
// Handle select all
const handleSelectAll = () => {
if (!selectable) return;
// Get only selectable rows
const selectableIndices = displayData
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index);
const selectableRows = displayData.filter(row => !isRowSelectable || isRowSelectable(row));
const selectableIds = selectableRows.map(row => _getRowId(row));
if (selectedRows.size === selectableIndices.length) {
setSelectedRows(new Set());
onRowSelect?.([]);
if (selectedIds.size === selectableIds.length && selectableIds.every(id => selectedIds.has(id))) {
_notifySelection(new Set());
setSelectAllFilteredActive(false);
} else {
const allSelectableIndices = new Set(selectableIndices);
setSelectedRows(allSelectableIndices);
const selectableData = selectableIndices.map(i => displayData[i]);
onRowSelect?.(selectableData);
_notifySelection(new Set(selectableIds));
}
};
// Handle delete single item
const handleDeleteSingle = (row: T, index: number) => {
const handleSelectAllFiltered = useCallback(async () => {
if (selectAllFilteredActive) {
_notifySelection(new Set());
setSelectAllFilteredActive(false);
return;
}
if (!apiEndpoint || !supportsBackendPagination) return;
setSelectAllFilteredLoading(true);
try {
const params: Record<string, string> = { mode: 'ids' };
if (Object.keys(filters).length > 0 || searchTerm) {
const paginationFilters: Record<string, any> = { ...filters };
if (searchTerm) paginationFilters.search = searchTerm;
params.pagination = JSON.stringify({ filters: paginationFilters });
}
const response = await api.get(apiEndpoint, { params });
const allIds: string[] = Array.isArray(response.data) ? response.data : [];
_notifySelection(new Set(allIds));
setSelectAllFilteredActive(true);
} catch (e) {
console.error('Failed to fetch all filtered IDs:', e);
} finally {
setSelectAllFilteredLoading(false);
}
}, [selectAllFilteredActive, apiEndpoint, supportsBackendPagination, filters, searchTerm, _notifySelection]);
const handleDeleteSingle = (row: T, _index: number) => {
if (onDelete) {
onDelete(row);
// Remove from selection if it was selected
if (selectedRows.has(index)) {
const newSelected = new Set(selectedRows);
newSelected.delete(index);
setSelectedRows(newSelected);
if (onRowSelect) {
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onRowSelect(selectedData);
}
const rowId = _getRowId(row);
if (selectedIds.has(rowId)) {
const newSelected = new Set(selectedIds);
newSelected.delete(rowId);
_notifySelection(newSelected);
}
}
};
// Handle delete multiple items
const handleDeleteMultiple = () => {
if (onDeleteMultiple && selectedRows.size > 0) {
const selectedData = Array.from(selectedRows).map(i => displayData[i]);
if (onDeleteMultiple && selectedIds.size > 0) {
const selectedData = displayData.filter(row => selectedIds.has(_getRowId(row)));
onDeleteMultiple(selectedData);
// Clear selection
setSelectedRows(new Set());
onRowSelect?.([]);
_notifySelection(new Set());
}
};
@ -1669,7 +1748,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return (
<div className={`${styles.formGeneratorTable} ${className}`}>
{(searchable || (selectable && selectedRows.size > 0)) && (
{(searchable || (selectable && selectedIds.size > 0)) && (
<FormGeneratorControls
fields={detectedColumns}
searchTerm={searchTerm}
@ -1680,12 +1759,12 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onFilterChange={handleFilter}
filterFocused={filterFocused}
onFilterFocus={handleFilterFocus}
selectedCount={selectedRows.size}
selectedCount={selectedIds.size}
displayData={displayData}
onDeleteSingle={selectedRows.size === 1 && onDelete ? () => {
const selectedIndex = Array.from(selectedRows)[0];
const selectedRow = displayData[selectedIndex];
handleDeleteSingle(selectedRow, selectedIndex);
onDeleteSingle={selectedIds.size === 1 && onDelete ? () => {
const selectedId = Array.from(selectedIds)[0];
const selectedRow = displayData.find(row => _getRowId(row) === selectedId);
if (selectedRow) handleDeleteSingle(selectedRow, 0);
} : undefined}
onDeleteMultiple={(() => {
if (!onDeleteMultiple) return undefined;
@ -1696,24 +1775,33 @@ export function FormGeneratorTable<T extends Record<string, any>>({
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index);
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
const allSelected = selectedIds.size === selectableIndices.length && selectableIndices.length > 0;
return (selectedIds.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
})()}
batchActions={batchActions.length > 0 ? batchActions.map((ba) => ({
label: ba.label,
icon: ba.icon,
loading: ba.loading,
onClick: async () => {
const rows = Array.from(selectedRows).map((i) => displayData[i]);
try {
await Promise.resolve(ba.onClick(rows));
setSelectedRows(new Set());
onRowSelect?.([]);
} catch {
// Keep selection on error so user can retry
}
},
})) : undefined}
batchActions={batchActions.length > 0 ? batchActions.map((ba) => {
const allSelectedRows = displayData.filter(row => selectedIds.has(_getRowId(row)));
const applicableRows = ba.isApplicable
? allSelectedRows.filter(ba.isApplicable)
: allSelectedRows;
const totalSelected = selectedIds.size;
const applicableCount = applicableRows.length;
const showCount = ba.isApplicable && applicableCount !== totalSelected;
return {
label: showCount ? `${ba.label} (${applicableCount}/${totalSelected})` : ba.label,
icon: ba.icon,
loading: ba.loading,
disabled: applicableCount === 0,
onClick: async () => {
if (applicableCount === 0) return;
try {
await Promise.resolve(ba.onClick(applicableRows));
_notifySelection(new Set());
} catch {
// Keep selection on error so user can retry
}
},
};
}) : undefined}
onRefresh={onRefresh}
searchable={searchable}
selectable={selectable}
@ -1731,6 +1819,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
hookData={hookData}
onCsvExport={apiEndpoint ? handleCsvExport : undefined}
csvExporting={csvExporting}
totalFilteredItems={hookData?.pagination?.totalItems}
onSelectAllFiltered={apiEndpoint && supportsBackendPagination && selectable ? handleSelectAllFiltered : undefined}
selectAllFilteredActive={selectAllFilteredActive}
selectAllFilteredLoading={selectAllFilteredLoading}
/>
)}
@ -1769,7 +1861,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index);
return selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
return selectedIds.size === selectableIndices.length && selectableIndices.length > 0;
})()}
onChange={handleSelectAll}
title={t('Alle Elemente auswählen')}
@ -1969,7 +2061,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
allValues={getUniqueValuesForColumn(column.key)}
activeFilter={filters[column.key]}
onSelect={(value) => handleFilter(column.key, value)}
resolveLabel={column.fkSource ? (val) => fkCache[column.fkSource!]?.[val] || val : undefined}
resolveLabel={column.filterLabelResolver || (column.fkSource ? (val) => fkCache[column.fkSource!]?.[val] || val : undefined)}
/>
)}
</>
@ -2068,7 +2160,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return (
<tr
key={`${groupKey}-row-${rowIndex}`}
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
className={`${styles.tr} ${selectedIds.has(_getRowId(row)) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row, globalIndex)}
draggable={rowDraggable}
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
@ -2080,8 +2172,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input
type="checkbox"
checked={selectedRows.has(globalIndex)}
onChange={() => handleRowSelect(globalIndex)}
checked={selectedIds.has(_getRowId(row))}
onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)}
title={
@ -2184,7 +2276,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return (
<tr
key={index}
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
className={`${styles.tr} ${selectedIds.has(_getRowId(row)) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row, index)}
draggable={rowDraggable}
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
@ -2196,8 +2288,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input
type="checkbox"
checked={selectedRows.has(index)}
onChange={() => handleRowSelect(index)}
checked={selectedIds.has(_getRowId(row))}
onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)}
title={

View file

@ -303,6 +303,31 @@ export function useFeatureAccess() {
}
}, []);
/**
* Sync workflows for a feature instance from templates
*/
const syncInstanceWorkflows = useCallback(async (
mandateId: string,
instanceId: string
): Promise<{ success: boolean; data?: { added: number; skipped: number; total: number }; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.post(`/api/features/instances/${instanceId}/sync-workflows`, {}, {
headers: {
'X-Mandate-Id': mandateId
}
});
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to sync instance workflows';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Get current user's feature instances (grouped by mandate)
*/
@ -495,6 +520,7 @@ export function useFeatureAccess() {
updateInstance,
deleteInstance,
syncInstanceRoles,
syncInstanceWorkflows,
fetchMyFeatureInstances,
fetchTemplateRoles,
// Instance users management

View file

@ -15,7 +15,7 @@ import { useToast } from '../contexts/ToastContext';
import { usePrompt } from '../hooks/usePrompt';
import { useApiRequest } from '../hooks/useApi';
import { formatUnixTimestamp } from '../utils/time';
import { updateWorkflow, executeGraph, deleteWorkflow } from '../api/workflowApi';
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
import styles from './admin/Admin.module.css';
@ -392,6 +392,7 @@ const _DashboardTab: React.FC = () => {
const [loading, setLoading] = useState(true);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
const lastPaginationParamsRef = useRef<any>(null);
const _loadMetrics = useCallback(async () => {
try {
@ -403,14 +404,19 @@ const _DashboardTab: React.FC = () => {
}, []);
const _loadRuns = useCallback(async (paginationParams?: any) => {
if (paginationParams !== undefined) {
lastPaginationParamsRef.current = paginationParams;
}
const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
setLoading(true);
try {
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
const pag = {
page: paginationParams?.page || 1,
pageSize: paginationParams?.pageSize || 25,
...(paginationParams?.sort ? { sort: paginationParams.sort } : {}),
...(paginationParams?.search ? { search: paginationParams.search } : {}),
...(paginationParams?.filters ? { filters: paginationParams.filters } : {}),
page: effectiveParams?.page || 1,
pageSize: effectiveParams?.pageSize || 25,
sort: effectiveParams?.sort || defaultSort,
...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
};
const params: Record<string, any> = { pagination: JSON.stringify(pag) };
const resp = await api.get('/api/system/workflow-runs', { params });
@ -474,6 +480,15 @@ const _DashboardTab: React.FC = () => {
}
}, [showError, t]);
const _STATUS_LABELS: Record<string, string> = useMemo(() => ({
running: t('Laufend'),
completed: t('Abgeschlossen'),
failed: t('Fehlgeschlagen'),
cancelled: t('Abgebrochen'),
paused: t('Pausiert'),
stopped: t('Gestoppt'),
}), [t]);
const _runColumns: ColumnConfig[] = useMemo(() => [
{
key: 'workflowLabel',
@ -484,20 +499,24 @@ const _DashboardTab: React.FC = () => {
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
},
{
key: 'mandateLabel',
key: 'mandateId',
label: t('Mandant'),
type: 'string',
width: 140,
sortable: true,
filterable: true,
fkSource: '/api/mandates/',
fkDisplayField: 'label',
},
{
key: 'instanceLabel',
key: 'featureInstanceId',
label: t('Instanz'),
type: 'string',
width: 140,
sortable: true,
filterable: true,
fkSource: '/api/features/instances',
fkDisplayField: 'label',
},
{
key: 'status',
@ -507,9 +526,10 @@ const _DashboardTab: React.FC = () => {
sortable: true,
filterable: true,
filterOptions: ['running', 'completed', 'failed', 'cancelled', 'paused'],
filterLabelResolver: (v: string) => _STATUS_LABELS[v] || v,
formatter: (v: string) => (
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600 }}>
{v === 'completed' ? t('Abgeschlossen') : v === 'failed' ? t('Fehlgeschlagen') : v === 'running' ? t('Laufend') : v}
{_STATUS_LABELS[v] || v}
</span>
),
},
@ -526,9 +546,10 @@ const _DashboardTab: React.FC = () => {
label: t('Beendet'),
type: 'number',
width: 150,
sortable: true,
formatter: (v: number) => _formatTs(v),
},
], [t]);
], [t, _STATUS_LABELS]);
const _hookData = useMemo(() => ({
refetch: _loadRuns,
@ -605,7 +626,8 @@ const _DashboardTab: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
apiEndpoint="/api/system/workflow-runs"
customActions={[
{
@ -649,20 +671,26 @@ const _WorkflowsTab: React.FC = () => {
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const lastPaginationParamsRef = useRef<any>(null);
const _load = useCallback(async (paginationParams?: any) => {
if (paginationParams !== undefined) {
lastPaginationParamsRef.current = paginationParams;
}
const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
setLoading(true);
try {
const params: Record<string, any> = {};
if (activeFilter === 'active') params.active = true;
if (activeFilter === 'inactive') params.active = false;
const defaultSort = [{ field: 'createdAt', direction: 'desc' }];
const pag = {
page: paginationParams?.page || 1,
pageSize: paginationParams?.pageSize || 25,
...(paginationParams?.sort ? { sort: paginationParams.sort } : {}),
...(paginationParams?.search ? { search: paginationParams.search } : {}),
...(paginationParams?.filters ? { filters: paginationParams.filters } : {}),
page: effectiveParams?.page || 1,
pageSize: effectiveParams?.pageSize || 25,
sort: effectiveParams?.sort || defaultSort,
...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
};
params.pagination = JSON.stringify(pag);
@ -691,14 +719,13 @@ const _WorkflowsTab: React.FC = () => {
const _handleEdit = useCallback((row: SystemWorkflow) => {
if (!row.mandateId || !row.featureInstanceId) return;
navigate(`/mandates/${row.mandateId}/graphicalEditor/${row.featureInstanceId}/editor?workflowId=${row.id}`);
const fc = (row as any).featureCode || 'graphicalEditor';
navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
}, [navigate]);
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
const wf = workflows.find(w => w.id === workflowId);
if (!wf?.featureInstanceId) return false;
try {
await deleteWorkflow(request, wf.featureInstanceId, workflowId);
await deleteSystemWorkflow(request, workflowId);
showSuccess(t('Workflow gelöscht'));
await _load();
return true;
@ -706,7 +733,7 @@ const _WorkflowsTab: React.FC = () => {
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
return false;
}
}, [workflows, request, showSuccess, showError, _load, t]);
}, [request, showSuccess, showError, _load, t]);
const _handleToggleActive = useCallback(async (row: SystemWorkflow) => {
if (!row.featureInstanceId) return;
@ -794,28 +821,22 @@ const _WorkflowsTab: React.FC = () => {
}, []);
const _columns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
{ key: 'mandateLabel', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true },
{ key: 'instanceLabel', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true },
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true },
{ key: 'mandateId', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/mandates/', fkDisplayField: 'label' },
{ key: 'featureInstanceId', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/features/instances', fkDisplayField: 'label' },
{
key: 'active',
label: t('Aktiv (Spalte)'),
label: t('Aktiv'),
type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value !== false
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
sortable: true,
filterable: true,
},
{
key: 'isRunning',
label: t('läuft'),
label: t('Läuft'),
type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
},
{
key: 'sysCreatedAt',
@ -827,7 +848,7 @@ const _WorkflowsTab: React.FC = () => {
},
{
key: 'lastStartedAt',
label: t('zuletzt gestartet'),
label: t('Zuletzt gestartet'),
type: 'number',
width: 160,
formatter: (v: number) => _formatTs(v),
@ -884,7 +905,8 @@ const _WorkflowsTab: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
initialSort={[{ key: 'createdAt', direction: 'desc' }]}
apiEndpoint="/api/system/workflow-runs/workflows"
actionButtons={[
{

View file

@ -11,6 +11,7 @@ import api from '../../api';
import styles from './Admin.module.css';
import demoStyles from './AdminDemoConfigPage.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useConfirm } from '../../hooks/useConfirm';
interface _DemoConfig {
code: string;
@ -28,6 +29,7 @@ interface _ActionResult {
export const AdminDemoConfigPage: React.FC = () => {
const { t } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm();
const [configs, setConfigs] = useState<_DemoConfig[]>([]);
const [loading, setLoading] = useState(false);
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
@ -67,7 +69,11 @@ export const AdminDemoConfigPage: React.FC = () => {
const _handleRemove = async (code: string) => {
if (actionInProgress) return;
if (!window.confirm(t('Are you sure you want to remove all demo data for this configuration?'))) return;
const ok = await confirm(
t('Alle Demo-Daten für diese Konfiguration wirklich entfernen?'),
{ confirmLabel: t('Entfernen'), cancelLabel: t('Abbrechen'), variant: 'danger' },
);
if (!ok) return;
setActionInProgress(code);
setLastResult(null);
try {
@ -143,6 +149,8 @@ export const AdminDemoConfigPage: React.FC = () => {
))}
</div>
)}
<ConfirmDialog />
</div>
);
};

View file

@ -36,6 +36,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
updateInstance,
deleteInstance,
syncInstanceRoles,
syncInstanceWorkflows,
} = useFeatureAccess();
const { fetchMandates } = useUserMandates();
@ -50,6 +51,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
const [editingInstance, setEditingInstance] = useState<FeatureInstance | null>(null);
const [, setIsSubmitting] = useState(false);
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
const [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = useState<string | null>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Chatbot configuration state
@ -312,6 +314,29 @@ export const AdminFeatureAccessPage: React.FC = () => {
}
};
// Handle sync workflows
const _handleSyncWorkflows = async (instance: FeatureInstance) => {
if (!selectedMandateId) return;
setSyncingWorkflowsInstance(instance.id);
try {
const result = await syncInstanceWorkflows(selectedMandateId, instance.id);
if (result.success && result.data) {
showSuccess(
t('Workflows synchronisiert'),
t('Hinzugefügt: {added}\nÜbersprungen: {skipped}\nTotal Templates: {total}', {
added: result.data.added,
skipped: result.data.skipped,
total: result.data.total,
})
);
} else {
showError(t('Synchronisierung fehlgeschlagen'), result.error || t('Fehler beim Synchronisieren der Workflows'));
}
} finally {
setSyncingWorkflowsInstance(null);
}
};
// Get mandate name
const getMandateName = (mandate: Mandate) => {
return mandate.label || mandate.name || mandate.id;
@ -444,7 +469,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'delete' as const,
@ -465,6 +490,14 @@ export const AdminFeatureAccessPage: React.FC = () => {
title: t('Rollen synchronisieren'),
loading: (row: FeatureInstance) => syncingInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled,
},
{
id: 'syncWorkflows',
icon: <FaSync />,
onClick: _handleSyncWorkflows,
title: t('Workflows synchronisieren'),
loading: (row: FeatureInstance) => syncingWorkflowsInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled,
}
]}
hookData={{

View file

@ -528,7 +528,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'edit' as const,

View file

@ -364,7 +364,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'edit' as const,

View file

@ -345,7 +345,7 @@ export const AdminInvitationsPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'delete' as const,

View file

@ -407,7 +407,7 @@ export const AdminMandateRolesPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'edit' as const,

View file

@ -214,7 +214,7 @@ export const AdminMandatesPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,

View file

@ -342,7 +342,7 @@ export const AdminUserMandatesPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'edit' as const,

View file

@ -194,7 +194,7 @@ export const AdminUsersPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,

View file

@ -317,7 +317,7 @@ export const ConnectionsPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,

View file

@ -197,7 +197,7 @@ export const PromptsPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'copy' as const,

View file

@ -455,7 +455,7 @@ export const BillingDataView: React.FC = () => {
if (crossFilters && Object.keys(crossFilters).length > 0) {
params.pagination = JSON.stringify({ filters: crossFilters });
}
const resp = await api.get('/api/billing/view/users/transactions/filter-values', { params });
const resp = await api.get('/api/billing/view/users/transactions', { params: { ...params, mode: 'filterValues' } });
return Array.isArray(resp.data) ? resp.data : [];
}, [_scopeParams]);

View file

@ -263,7 +263,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'edit',

View file

@ -294,7 +294,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
{
type: 'edit',

View file

@ -176,7 +176,7 @@ export const RealEstateParcelsView: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
...(canUpdate
? [

View file

@ -162,7 +162,7 @@ export const RealEstateProjectsView: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten') }] : []),
...(canDelete ? [{ type: 'delete' as const, title: t('Löschen'), loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []),

View file

@ -14,6 +14,7 @@ import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
import styles from './TrusteeViews.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { FaUpload, FaTimes } from 'react-icons/fa';
// ---------------------------------------------------------------------------
// Tab definitions
@ -90,6 +91,14 @@ export const TrusteeAnalyseView: React.FC = () => {
const pollTimerRef = useRef<number | null>(null);
const isPollingRef = useRef(false);
const [resultText, setResultText] = useState<string | null>(null);
const [resultDocuments, setResultDocuments] = useState<Array<{ id?: string; fileName?: string; mimeType?: string }>>([]);
const [budgetFileId, setBudgetFileId] = useState<string | null>(null);
const [budgetFileName, setBudgetFileName] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load workflows for this instance once
useEffect(() => {
if (!instanceId) return;
@ -151,6 +160,12 @@ export const TrusteeAnalyseView: React.FC = () => {
if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
setRunState('completed');
_stopPolling();
const lastStep = [...steps].reverse().find((s) => s.status === 'completed' && s.output);
if (lastStep?.output) {
setResultText(lastStep.output.response || lastStep.output.context || null);
const docs = lastStep.output.documents || lastStep.output.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
}
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
return;
}
@ -177,6 +192,25 @@ export const TrusteeAnalyseView: React.FC = () => {
useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
const _extractResults = useCallback((nodeOutputs: Record<string, any>) => {
const analyseOut = nodeOutputs?.analyse || nodeOutputs?.result;
if (!analyseOut) {
for (const key of Object.keys(nodeOutputs || {})) {
const v = nodeOutputs[key];
if (v && typeof v === 'object' && (v.response || v.documents)) {
setResultText(v.response || v.context || null);
const docs = v.documents || v.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
return;
}
}
return;
}
setResultText(analyseOut.response || analyseOut.context || null);
const docs = analyseOut.documents || analyseOut.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
}, []);
// Reset run state when tab changes
useEffect(() => {
_stopPolling();
@ -184,8 +218,36 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunId(null);
setRunSummary('');
setRunError(null);
setResultText(null);
setResultDocuments([]);
}, [activeTab, _stopPolling]);
const _handleBudgetUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !instanceId) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('featureInstanceId', instanceId);
const res = await api.post('/api/files/upload', formData);
const fileData = res.data?.file || res.data;
setBudgetFileId(fileData.id);
setBudgetFileName(fileData.fileName || file.name);
showSuccess(t('Datei hochgeladen'), file.name);
} catch (err: any) {
showError(t('Upload fehlgeschlagen'), err.message || t('Datei konnte nicht hochgeladen werden.'));
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}, [instanceId, showSuccess, showError, t]);
const _handleRemoveBudgetFile = useCallback(() => {
setBudgetFileId(null);
setBudgetFileName(null);
}, []);
// Execute workflow
const _handleExecute = useCallback(async () => {
const wf = _findWorkflow(activeTab);
@ -193,11 +255,21 @@ export const TrusteeAnalyseView: React.FC = () => {
showError(t('Fehler'), t('Kein Workflow für diesen Tab gefunden.'));
return;
}
if (activeTab === 'budget' && !budgetFileId) {
showError(t('Budget-Datei fehlt'), t('Bitte laden Sie zuerst die Budget-Excel-Datei hoch.'));
return;
}
setRunState('starting');
setRunError(null);
setRunSummary(t('Workflow wird gestartet…'));
setResultText(null);
setResultDocuments([]);
try {
const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
const executeBody: Record<string, any> = { workflowId: wf.id };
if (activeTab === 'budget' && budgetFileId) {
executeBody.payload = { documentList: [budgetFileId] };
}
const res = await api.post(`/api/workflows/${instanceId}/execute`, executeBody);
const rid = res?.data?.runId;
if (rid) {
setRunId(rid);
@ -206,6 +278,9 @@ export const TrusteeAnalyseView: React.FC = () => {
} else if (res?.data?.success) {
setRunState('completed');
setRunSummary(t('Workflow synchron abgeschlossen.'));
if (res.data.nodeOutputs) {
_extractResults(res.data.nodeOutputs);
}
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
} else {
throw new Error(res?.data?.error || t('Unerwartete Antwort'));
@ -216,7 +291,7 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
}
}, [activeTab, instanceId, _findWorkflow, showError, showSuccess, t]);
}, [activeTab, instanceId, _findWorkflow, budgetFileId, showError, showSuccess, t]);
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
const currentWorkflow = _findWorkflow(activeTab);
@ -275,10 +350,56 @@ export const TrusteeAnalyseView: React.FC = () => {
</div>
</div>
{activeTab === 'budget' && (
<div style={{
padding: '1rem',
border: '1px dashed var(--border-color, #ccc)',
borderRadius: '8px',
background: 'var(--bg-secondary, #f9f9f9)',
}}>
<div style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem' }}>
{t('Budget-Excel hochladen')}
</div>
{budgetFileName ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem' }}>📄 {budgetFileName}</span>
<button
onClick={_handleRemoveBudgetFile}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text-secondary, #666)', padding: '0.25rem',
}}
title={t('Datei entfernen')}
>
<FaTimes />
</button>
</div>
) : (
<label style={{
display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 1rem', borderRadius: '6px', cursor: uploading ? 'wait' : 'pointer',
border: '1px solid var(--border-color, #ccc)', background: 'var(--bg-primary, #fff)',
fontSize: '0.875rem', color: 'var(--text-primary, #333)',
}}>
<FaUpload />
{uploading ? t('Wird hochgeladen…') : t('Excel-Datei auswählen')}
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
onChange={_handleBudgetUpload}
disabled={uploading}
style={{ display: 'none' }}
/>
</label>
)}
</div>
)}
<button
className={styles.primaryButton}
onClick={_handleExecute}
disabled={runState === 'starting' || runState === 'running'}
disabled={runState === 'starting' || runState === 'running' || (activeTab === 'budget' && !budgetFileId)}
style={{ alignSelf: 'flex-start' }}
>
{runState === 'starting' || runState === 'running'
@ -300,6 +421,61 @@ export const TrusteeAnalyseView: React.FC = () => {
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
</div>
)}
{/* Results */}
{runState === 'completed' && (resultText || resultDocuments.length > 0) && (
<div style={{
marginTop: '0.5rem',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: '8px',
overflow: 'hidden',
}}>
{resultDocuments.length > 0 && (
<div style={{
padding: '0.75rem 1rem',
background: 'var(--bg-secondary, #f9f9f9)',
borderBottom: resultText ? '1px solid var(--border-color, #e0e0e0)' : 'none',
display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center',
}}>
<strong style={{ fontSize: '0.8125rem' }}>{t('Generierte Dokumente:')}</strong>
{resultDocuments.map((doc, idx) => {
const docId = doc.id || (typeof doc === 'string' ? doc : null);
const docName = doc.fileName || `Dokument ${idx + 1}`;
if (!docId) return null;
return (
<a
key={docId}
href={`${api.defaults.baseURL || ''}/api/files/${docId}/download`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem', borderRadius: '6px',
background: 'var(--primary-color, #007bff)', color: '#fff',
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
}}
>
📄 {docName}
</a>
);
})}
</div>
)}
{resultText && (
<div style={{
padding: '1rem',
fontSize: '0.875rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
maxHeight: '400px',
overflowY: 'auto',
background: 'var(--bg-primary, #fff)',
}}>
{resultText}
</div>
)}
</div>
)}
</div>
</div>
</div>

View file

@ -218,6 +218,7 @@ export const TrusteeDocumentsView: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,

View file

@ -185,7 +185,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
searchable={true}
filterable={true}
sortable={true}
selectable={false}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,

View file

@ -440,6 +440,7 @@ export const TrusteePositionsView: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
batchActions={[
{
label: t('Mit Buchhaltung synchronisieren'),

View file

@ -511,7 +511,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onChange={_handleChange}
onKeyDown={_handleKeyDown}
onPaste={_handlePaste}
placeholder={t('Geben Sie eine Nachricht ein, verwenden Sie {filename}')}
placeholder={t('Geben Sie eine Nachricht ein, verwenden Sie @file für Dateien')}
disabled={isProcessing}
style={{
flex: 1,