From b76947d613e94b39ddeaa49031b5eb1fa32cb4b7 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 00:15:51 +0200 Subject: [PATCH] fixed formgenerator , trustee, sort and filter --- src/App.tsx | 3 + src/api/workflowApi.ts | 11 + .../FormGeneratorControls.tsx | 36 ++- .../FormGeneratorTable/FormGeneratorTable.tsx | 282 ++++++++++++------ src/hooks/useFeatureAccess.ts | 26 ++ src/pages/AutomationsDashboardPage.tsx | 94 +++--- src/pages/admin/AdminDemoConfigPage.tsx | 10 +- src/pages/admin/AdminFeatureAccessPage.tsx | 35 ++- .../admin/AdminFeatureInstanceUsersPage.tsx | 2 +- src/pages/admin/AdminFeatureRolesPage.tsx | 2 +- src/pages/admin/AdminInvitationsPage.tsx | 2 +- src/pages/admin/AdminMandateRolesPage.tsx | 2 +- src/pages/admin/AdminMandatesPage.tsx | 2 +- src/pages/admin/AdminUserMandatesPage.tsx | 2 +- src/pages/admin/AdminUsersPage.tsx | 2 +- src/pages/basedata/ConnectionsPage.tsx | 2 +- src/pages/basedata/PromptsPage.tsx | 2 +- src/pages/billing/BillingDataView.tsx | 2 +- .../GraphicalEditorTemplatesPage.tsx | 2 +- .../GraphicalEditorWorkflowsPage.tsx | 2 +- .../realestate/RealEstateParcelsView.tsx | 2 +- .../realestate/RealEstateProjectsView.tsx | 2 +- .../views/trustee/TrusteeAnalyseView.tsx | 182 ++++++++++- .../views/trustee/TrusteeDocumentsView.tsx | 1 + .../trustee/TrusteePositionDocumentsView.tsx | 2 +- .../views/trustee/TrusteePositionsView.tsx | 1 + src/pages/views/workspace/WorkspaceInput.tsx | 2 +- 27 files changed, 557 insertions(+), 156 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9ef841f..8012870 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -177,6 +177,9 @@ function App() { } /> } /> + {/* Neutralization Feature Views */} + } /> + {/* CommCoach Feature Views */} } /> } /> diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 894ce14..347ad0b 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -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 { + await request({ + url: `/api/system/workflow-runs/workflows/${workflowId}`, + method: 'delete', + }); +} + export interface Automation2Run { id: string; workflowId: string; diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index d565e36..9b3e446 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -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; 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} ))} + {onSelectAllFiltered && totalFilteredItems !== undefined && totalFilteredItems > selectedCount && !selectAllFilteredActive && ( + + )} + {selectAllFilteredActive && ( + + )} )} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 6f7a6aa..807ad5e 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -18,7 +18,7 @@ * fetchFilterValues, // (columnKey: string) => Promise (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 { showPageSizeSelector?: boolean; onRowClick?: (row: T, index: number) => void; onRowSelect?: (selectedRows: T[]) => void; + onSelectionChange?: (selectedIds: Set) => void; selectable?: boolean; isRowSelectable?: (row: T) => boolean; loading?: boolean; @@ -176,6 +178,7 @@ export interface FormGeneratorTableProps { onClick: (rows: T[]) => void | Promise; loading?: boolean; icon?: IconType; + isApplicable?: (row: T) => boolean; }[]; onRefresh?: () => void; className?: string; @@ -193,6 +196,7 @@ export interface FormGeneratorTableProps { groupDefaultExpanded?: boolean; groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode; initialSearchTerm?: string; + initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>; rowDraggable?: boolean; onRowDragStart?: (e: React.DragEvent, 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(null); + const searchInputRef = useRef(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 && ( +
+ { 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()} + /> +
+ )} {visibleValues.map(value => { const label = resolveLabel ? resolveLabel(value) : value; return ( @@ -256,9 +298,14 @@ function FilterValuesList({ ); })} - {displayCount < allValues.length && ( + {displayCount < filteredValues.length && (
)} + {showSearch && searchTerm && filteredValues.length === 0 && ( +
+ Keine Treffer +
+ )} ); } @@ -276,7 +323,8 @@ export function FormGeneratorTable>({ 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>({ groupDefaultExpanded = true, groupActions, initialSearchTerm = '', + initialSort, rowDraggable = false, onRowDragStart, }: FormGeneratorTableProps) { @@ -341,12 +390,16 @@ export function FormGeneratorTable>({ const [searchFocused, setSearchFocused] = useState(false); const [filterFocused, setFilterFocused] = useState>({}); // Multi-column sorting: array of sort configs in order of priority - const [sortConfigs, setSortConfigs] = useState>([]); + const [sortConfigs, setSortConfigs] = useState>(initialSort ?? []); const [filters, setFilters] = useState>({}); const [columnWidths, setColumnWidths] = useState>({}); // Actions column width - resizable, default based on number of buttons const [actionsColumnWidth, setActionsColumnWidth] = useState(null); - const [selectedRows, setSelectedRows] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(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(null); @@ -639,7 +692,7 @@ export function FormGeneratorTable>({ 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>({ // 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>({ }); values = await hookData.fetchFilterValues(columnKey, crossFilters); } else if (apiEndpoint && supportsBackendPagination) { - const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint; - const params: Record = { column: columnKey }; + const params: Record = { 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>({ 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(); data.forEach(row => { const val = row[columnKey]; @@ -937,7 +990,7 @@ export function FormGeneratorTable>({ 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>({ setOpenFilterColumn(prev => prev === columnKey ? null : columnKey); }, []); - // Handle row selection - const handleRowSelect = (index: number) => { + const _notifySelection = useCallback((newIds: Set) => { + 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 = { mode: 'ids' }; + if (Object.keys(filters).length > 0 || searchTerm) { + const paginationFilters: Record = { ...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>({ return (
- {(searchable || (selectable && selectedRows.size > 0)) && ( + {(searchable || (selectable && selectedIds.size > 0)) && ( >({ 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>({ .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>({ 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>({ .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>({ 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>({ return ( onRowClick?.(row, globalIndex)} draggable={rowDraggable} onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} @@ -2080,8 +2172,8 @@ export function FormGeneratorTable>({ 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>({ return ( onRowClick?.(row, index)} draggable={rowDraggable} onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} @@ -2196,8 +2288,8 @@ export function FormGeneratorTable>({ handleRowSelect(index)} + checked={selectedIds.has(_getRowId(row))} + onChange={() => handleRowSelect(row)} onClick={(e) => e.stopPropagation()} disabled={isRowSelectable && !isRowSelectable(row)} title={ diff --git a/src/hooks/useFeatureAccess.ts b/src/hooks/useFeatureAccess.ts index e67658e..658d32a 100644 --- a/src/hooks/useFeatureAccess.ts +++ b/src/hooks/useFeatureAccess.ts @@ -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 diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index 4d4d1a5..d229ffe 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -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(null); const [tracingRun, setTracingRun] = useState(null); + const lastPaginationParamsRef = useRef(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 = { 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 = 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) => ( - {v === 'completed' ? t('Abgeschlossen') : v === 'failed' ? t('Fehlgeschlagen') : v === 'running' ? t('Laufend') : v} + {_STATUS_LABELS[v] || v} ), }, @@ -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(null); const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [paginationMeta, setPaginationMeta] = useState(null); + const lastPaginationParamsRef = useRef(null); const _load = useCallback(async (paginationParams?: any) => { + if (paginationParams !== undefined) { + lastPaginationParamsRef.current = paginationParams; + } + const effectiveParams = paginationParams ?? lastPaginationParamsRef.current; setLoading(true); try { const params: Record = {}; 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 => { - 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 - ? {t('Ja')} - : {t('Nein')}, + sortable: true, + filterable: true, }, { key: 'isRunning', - label: t('läuft'), + label: t('Läuft'), type: 'boolean', width: 80, - formatter: (value: boolean) => - value - ? {t('Ja')} - : {t('Nein')}, }, { 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={[ { diff --git a/src/pages/admin/AdminDemoConfigPage.tsx b/src/pages/admin/AdminDemoConfigPage.tsx index 243c50f..975cea4 100644 --- a/src/pages/admin/AdminDemoConfigPage.tsx +++ b/src/pages/admin/AdminDemoConfigPage.tsx @@ -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(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 = () => { ))}
)} + +
); }; diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 81973bb..0f18c62 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -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(null); const [, setIsSubmitting] = useState(false); const [syncingInstance, setSyncingInstance] = useState(null); + const [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = useState(null); const [backendAttributes, setBackendAttributes] = useState([]); // 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: , + onClick: _handleSyncWorkflows, + title: t('Workflows synchronisieren'), + loading: (row: FeatureInstance) => syncingWorkflowsInstance === row.id, + disabled: (row: FeatureInstance) => !row.enabled, } ]} hookData={{ diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index e69068d..8dca85a 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -528,7 +528,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'edit' as const, diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index faed9ae..38a2895 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -364,7 +364,7 @@ export const AdminFeatureRolesPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'edit' as const, diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 032d960..0b1c7e0 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -345,7 +345,7 @@ export const AdminInvitationsPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'delete' as const, diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index a9c6690..6156787 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -407,7 +407,7 @@ export const AdminMandateRolesPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'edit' as const, diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx index 673ff0d..f48e8f4 100644 --- a/src/pages/admin/AdminMandatesPage.tsx +++ b/src/pages/admin/AdminMandatesPage.tsx @@ -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, diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx index 0be162c..3af48eb 100644 --- a/src/pages/admin/AdminUserMandatesPage.tsx +++ b/src/pages/admin/AdminUserMandatesPage.tsx @@ -342,7 +342,7 @@ export const AdminUserMandatesPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'edit' as const, diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 71e38ef..eb05462 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -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, diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index be8ff49..20288a9 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -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, diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 4a97e3e..2fa7740 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -197,7 +197,7 @@ export const PromptsPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'copy' as const, diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index e73679e..bc4f017 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -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]); diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx index 68827bf..2cc4b2e 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx @@ -263,7 +263,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'edit', diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx index 72b6943..3a0d11c 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx @@ -294,7 +294,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ { type: 'edit', diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index d7a0642..cc41cef 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -176,7 +176,7 @@ export const RealEstateParcelsView: React.FC = () => { searchable={true} filterable={true} sortable={true} - selectable={false} + selectable={true} actionButtons={[ ...(canUpdate ? [ diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 34b1f5b..1a83e73 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -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) }] : []), diff --git a/src/pages/views/trustee/TrusteeAnalyseView.tsx b/src/pages/views/trustee/TrusteeAnalyseView.tsx index c32883f..2194842 100644 --- a/src/pages/views/trustee/TrusteeAnalyseView.tsx +++ b/src/pages/views/trustee/TrusteeAnalyseView.tsx @@ -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(null); const isPollingRef = useRef(false); + const [resultText, setResultText] = useState(null); + const [resultDocuments, setResultDocuments] = useState>([]); + + const [budgetFileId, setBudgetFileId] = useState(null); + const [budgetFileName, setBudgetFileName] = useState(null); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(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) => { + 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) => { + 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 = { 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 = () => { + {activeTab === 'budget' && ( +
+
+ {t('Budget-Excel hochladen')} +
+ {budgetFileName ? ( +
+ 📄 {budgetFileName} + +
+ ) : ( + + )} +
+ )} +