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="sessions" element={<FeatureViewPage view="sessions" />} />
<Route path="settings" element={<FeatureViewPage view="settings" />} /> <Route path="settings" element={<FeatureViewPage view="settings" />} />
{/* Neutralization Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />
{/* CommCoach Feature Views */} {/* CommCoach Feature Views */}
<Route path="coaching" element={<FeatureViewPage view="coaching" />} /> <Route path="coaching" element={<FeatureViewPage view="coaching" />} />
<Route path="dossier" element={<FeatureViewPage view="dossier" />} /> <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 { export interface Automation2Run {
id: string; id: string;
workflowId: string; workflowId: string;

View file

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

View file

@ -18,7 +18,7 @@
* fetchFilterValues, // (columnKey: string) => Promise<string[]> (Optional) * fetchFilterValues, // (columnKey: string) => Promise<string[]> (Optional)
* // If provided, called when a filter dropdown opens. * // If provided, called when a filter dropdown opens.
* // If NOT provided but apiEndpoint is set, the table * // 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, * Without hookData.refetch, interactive controls (sort, filter, search,
@ -28,7 +28,7 @@
* When a filterable column's dropdown opens, distinct values are loaded from: * When a filterable column's dropdown opens, distinct values are loaded from:
* 1. column.filterOptions (static enum used as-is, no backend call) * 1. column.filterOptions (static enum used as-is, no backend call)
* 2. hookData.fetchFilterValues(columnKey) if provided * 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, * Cross-filtering is supported: changing a filter invalidates the cache,
* so re-opening another column's dropdown re-fetches with current filters. * so re-opening another column's dropdown re-fetches with current filters.
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker. * Boolean columns render as "Ja"/"Nein"; date columns render as range picker.
@ -36,7 +36,7 @@
* BACKEND RESPONSE FORMAT (for refetch): * BACKEND RESPONSE FORMAT (for refetch):
* { items: T[], pagination: PaginationMetadata | null } * { items: T[], pagination: PaginationMetadata | null }
* *
* BACKEND RESPONSE FORMAT (for filter-values): * BACKEND RESPONSE FORMAT (for mode=filterValues):
* string[] * string[]
* *
* EXAMPLE (minimal integration): * EXAMPLE (minimal integration):
@ -108,6 +108,7 @@ export interface ColumnConfig {
searchable?: boolean; searchable?: boolean;
formatter?: (value: any, row: any) => React.ReactNode; formatter?: (value: any, row: any) => React.ReactNode;
filterOptions?: string[]; // For enum/select filters 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 cellClassName?: (value: any, row: any) => string; // For custom cell styling
fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/") 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") 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; showPageSizeSelector?: boolean;
onRowClick?: (row: T, index: number) => void; onRowClick?: (row: T, index: number) => void;
onRowSelect?: (selectedRows: T[]) => void; onRowSelect?: (selectedRows: T[]) => void;
onSelectionChange?: (selectedIds: Set<string>) => void;
selectable?: boolean; selectable?: boolean;
isRowSelectable?: (row: T) => boolean; isRowSelectable?: (row: T) => boolean;
loading?: boolean; loading?: boolean;
@ -176,6 +178,7 @@ export interface FormGeneratorTableProps<T = any> {
onClick: (rows: T[]) => void | Promise<void>; onClick: (rows: T[]) => void | Promise<void>;
loading?: boolean; loading?: boolean;
icon?: IconType; icon?: IconType;
isApplicable?: (row: T) => boolean;
}[]; }[];
onRefresh?: () => void; onRefresh?: () => void;
className?: string; className?: string;
@ -193,6 +196,7 @@ export interface FormGeneratorTableProps<T = any> {
groupDefaultExpanded?: boolean; groupDefaultExpanded?: boolean;
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode; groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
initialSearchTerm?: string; initialSearchTerm?: string;
initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>;
rowDraggable?: boolean; rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void; onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
} }
@ -217,32 +221,70 @@ function FilterValuesList({
resolveLabel?: (value: string) => string; resolveLabel?: (value: string) => string;
}) { }) {
const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE); const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE);
const [searchTerm, setSearchTerm] = useState('');
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
setDisplayCount(_FILTER_PAGE_SIZE); setDisplayCount(_FILTER_PAGE_SIZE);
setSearchTerm('');
}, [columnKey, allValues.length]); }, [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(() => { useEffect(() => {
const sentinel = sentinelRef.current; const sentinel = sentinelRef.current;
if (!sentinel || displayCount >= allValues.length) return; if (!sentinel || displayCount >= filteredValues.length) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0]?.isIntersecting) { 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 } { threshold: 0.1 }
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); 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 ( 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 => { {visibleValues.map(value => {
const label = resolveLabel ? resolveLabel(value) : value; const label = resolveLabel ? resolveLabel(value) : value;
return ( return (
@ -256,9 +298,14 @@ function FilterValuesList({
</div> </div>
); );
})} })}
{displayCount < allValues.length && ( {displayCount < filteredValues.length && (
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} /> <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, showPageSizeSelector = true,
onRowClick, onRowClick,
onRowSelect, onRowSelect,
selectable = true, // Default to true for selection functionality onSelectionChange,
selectable = true,
isRowSelectable, isRowSelectable,
loading = false, loading = false,
inlineEditable = true, // Enable inline editing by default inlineEditable = true, // Enable inline editing by default
@ -300,6 +348,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
groupDefaultExpanded = true, groupDefaultExpanded = true,
groupActions, groupActions,
initialSearchTerm = '', initialSearchTerm = '',
initialSort,
rowDraggable = false, rowDraggable = false,
onRowDragStart, onRowDragStart,
}: FormGeneratorTableProps<T>) { }: FormGeneratorTableProps<T>) {
@ -341,12 +390,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [searchFocused, setSearchFocused] = useState(false); const [searchFocused, setSearchFocused] = useState(false);
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({}); const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
// Multi-column sorting: array of sort configs in order of priority // 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 [filters, setFilters] = useState<Record<string, any>>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({}); const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
// Actions column width - resizable, default based on number of buttons // Actions column width - resizable, default based on number of buttons
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null); 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 [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null); 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 let displayLabel = item.id; // Fallback to ID
// Use the EXPLICIT display field from Pydantic model (fkDisplayField) // 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); displayLabel = convertToDisplayString(item[displayField], currentLanguage);
} else { } else {
// Fallback: if no displayField specified, try common fields // 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 // Skip if column has static filterOptions (enum) those are used directly
if (column?.filterOptions && column.filterOptions.length > 0) return; if (column?.filterOptions && column.filterOptions.length > 0) return;
// FK columns: extract values from actual data instead of backend endpoint // FK columns with backend pagination: still fetch from backend (data is only one page)
if (column?.fkSource) return; // 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 // Skip if already loaded or currently loading
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return; if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
@ -879,12 +933,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}); });
values = await hookData.fetchFilterValues(columnKey, crossFilters); values = await hookData.fetchFilterValues(columnKey, crossFilters);
} else if (apiEndpoint && supportsBackendPagination) { } else if (apiEndpoint && supportsBackendPagination) {
const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint; const params: Record<string, string> = { mode: 'filterValues', column: columnKey };
const params: Record<string, string> = { column: columnKey };
if (Object.keys(filters).length > 0) { if (Object.keys(filters).length > 0) {
params.pagination = JSON.stringify({ filters }); 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 : []; values = Array.isArray(response.data) ? response.data : [];
} else { } else {
values = []; values = [];
@ -913,8 +966,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return column.filterOptions; return column.filterOptions;
} }
// FK columns: extract distinct values from actual data (Excel autofilter style) // FK columns without backend pagination: extract from full local data
if (column?.fkSource) { if (column?.fkSource && !supportsBackendPagination) {
const seen = new Set<string>(); const seen = new Set<string>();
data.forEach(row => { data.forEach(row => {
const val = row[columnKey]; const val = row[columnKey];
@ -937,7 +990,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
console.warn( console.warn(
`FormGeneratorTable: Column "${columnKey}" is filterable ` + `FormGeneratorTable: Column "${columnKey}" is filterable ` +
`but has no filterOptions, no hookData.fetchFilterValues, and no apiEndpoint. ` + `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.` `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); setOpenFilterColumn(prev => prev === columnKey ? null : columnKey);
}, []); }, []);
// Handle row selection const _notifySelection = useCallback((newIds: Set<string>) => {
const handleRowSelect = (index: number) => { setSelectedIds(newIds);
if (!selectable) return; onSelectionChange?.(newIds);
if (onRowSelect) {
const rows = displayData.filter(row => newIds.has(_getRowId(row)));
onRowSelect(rows);
}
}, [displayData, onRowSelect, onSelectionChange, _getRowId]);
const row = displayData[index]; // 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;
if (isRowSelectable && !isRowSelectable(row)) return; if (isRowSelectable && !isRowSelectable(row)) return;
const newSelected = new Set(selectedRows); const rowId = _getRowId(row);
if (newSelected.has(index)) { const newSelected = new Set(selectedIds);
newSelected.delete(index); if (newSelected.has(rowId)) {
newSelected.delete(rowId);
} else { } else {
newSelected.add(index); newSelected.add(rowId);
}
setSelectedRows(newSelected);
if (onRowSelect) {
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onRowSelect(selectedData);
} }
_notifySelection(newSelected);
}; };
// Handle select all
const handleSelectAll = () => { const handleSelectAll = () => {
if (!selectable) return; if (!selectable) return;
// Get only selectable rows const selectableRows = displayData.filter(row => !isRowSelectable || isRowSelectable(row));
const selectableIndices = displayData const selectableIds = selectableRows.map(row => _getRowId(row));
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index);
if (selectedRows.size === selectableIndices.length) { if (selectedIds.size === selectableIds.length && selectableIds.every(id => selectedIds.has(id))) {
setSelectedRows(new Set()); _notifySelection(new Set());
onRowSelect?.([]); setSelectAllFilteredActive(false);
} else { } else {
const allSelectableIndices = new Set(selectableIndices); _notifySelection(new Set(selectableIds));
setSelectedRows(allSelectableIndices);
const selectableData = selectableIndices.map(i => displayData[i]);
onRowSelect?.(selectableData);
} }
}; };
// Handle delete single item const handleSelectAllFiltered = useCallback(async () => {
const handleDeleteSingle = (row: T, index: number) => { 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) { if (onDelete) {
onDelete(row); onDelete(row);
// Remove from selection if it was selected const rowId = _getRowId(row);
if (selectedRows.has(index)) { if (selectedIds.has(rowId)) {
const newSelected = new Set(selectedRows); const newSelected = new Set(selectedIds);
newSelected.delete(index); newSelected.delete(rowId);
setSelectedRows(newSelected); _notifySelection(newSelected);
if (onRowSelect) {
const selectedData = Array.from(newSelected).map(i => displayData[i]);
onRowSelect(selectedData);
}
} }
} }
}; };
// Handle delete multiple items
const handleDeleteMultiple = () => { const handleDeleteMultiple = () => {
if (onDeleteMultiple && selectedRows.size > 0) { if (onDeleteMultiple && selectedIds.size > 0) {
const selectedData = Array.from(selectedRows).map(i => displayData[i]); const selectedData = displayData.filter(row => selectedIds.has(_getRowId(row)));
onDeleteMultiple(selectedData); onDeleteMultiple(selectedData);
// Clear selection _notifySelection(new Set());
setSelectedRows(new Set());
onRowSelect?.([]);
} }
}; };
@ -1669,7 +1748,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return ( return (
<div className={`${styles.formGeneratorTable} ${className}`}> <div className={`${styles.formGeneratorTable} ${className}`}>
{(searchable || (selectable && selectedRows.size > 0)) && ( {(searchable || (selectable && selectedIds.size > 0)) && (
<FormGeneratorControls <FormGeneratorControls
fields={detectedColumns} fields={detectedColumns}
searchTerm={searchTerm} searchTerm={searchTerm}
@ -1680,12 +1759,12 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onFilterChange={handleFilter} onFilterChange={handleFilter}
filterFocused={filterFocused} filterFocused={filterFocused}
onFilterFocus={handleFilterFocus} onFilterFocus={handleFilterFocus}
selectedCount={selectedRows.size} selectedCount={selectedIds.size}
displayData={displayData} displayData={displayData}
onDeleteSingle={selectedRows.size === 1 && onDelete ? () => { onDeleteSingle={selectedIds.size === 1 && onDelete ? () => {
const selectedIndex = Array.from(selectedRows)[0]; const selectedId = Array.from(selectedIds)[0];
const selectedRow = displayData[selectedIndex]; const selectedRow = displayData.find(row => _getRowId(row) === selectedId);
handleDeleteSingle(selectedRow, selectedIndex); if (selectedRow) handleDeleteSingle(selectedRow, 0);
} : undefined} } : undefined}
onDeleteMultiple={(() => { onDeleteMultiple={(() => {
if (!onDeleteMultiple) return undefined; if (!onDeleteMultiple) return undefined;
@ -1696,24 +1775,33 @@ export function FormGeneratorTable<T extends Record<string, any>>({
.map((row, index) => ({ row, index })) .map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row)) .filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index); .map(({ index }) => index);
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0; const allSelected = selectedIds.size === selectableIndices.length && selectableIndices.length > 0;
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined; return (selectedIds.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
})()} })()}
batchActions={batchActions.length > 0 ? batchActions.map((ba) => ({ batchActions={batchActions.length > 0 ? batchActions.map((ba) => {
label: ba.label, 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, icon: ba.icon,
loading: ba.loading, loading: ba.loading,
disabled: applicableCount === 0,
onClick: async () => { onClick: async () => {
const rows = Array.from(selectedRows).map((i) => displayData[i]); if (applicableCount === 0) return;
try { try {
await Promise.resolve(ba.onClick(rows)); await Promise.resolve(ba.onClick(applicableRows));
setSelectedRows(new Set()); _notifySelection(new Set());
onRowSelect?.([]);
} catch { } catch {
// Keep selection on error so user can retry // Keep selection on error so user can retry
} }
}, },
})) : undefined} };
}) : undefined}
onRefresh={onRefresh} onRefresh={onRefresh}
searchable={searchable} searchable={searchable}
selectable={selectable} selectable={selectable}
@ -1731,6 +1819,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
hookData={hookData} hookData={hookData}
onCsvExport={apiEndpoint ? handleCsvExport : undefined} onCsvExport={apiEndpoint ? handleCsvExport : undefined}
csvExporting={csvExporting} 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 })) .map((row, index) => ({ row, index }))
.filter(({ row }) => !isRowSelectable || isRowSelectable(row)) .filter(({ row }) => !isRowSelectable || isRowSelectable(row))
.map(({ index }) => index); .map(({ index }) => index);
return selectedRows.size === selectableIndices.length && selectableIndices.length > 0; return selectedIds.size === selectableIndices.length && selectableIndices.length > 0;
})()} })()}
onChange={handleSelectAll} onChange={handleSelectAll}
title={t('Alle Elemente auswählen')} title={t('Alle Elemente auswählen')}
@ -1969,7 +2061,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
allValues={getUniqueValuesForColumn(column.key)} allValues={getUniqueValuesForColumn(column.key)}
activeFilter={filters[column.key]} activeFilter={filters[column.key]}
onSelect={(value) => handleFilter(column.key, value)} 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 ( return (
<tr <tr
key={`${groupKey}-row-${rowIndex}`} 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)} onClick={() => onRowClick?.(row, globalIndex)}
draggable={rowDraggable} draggable={rowDraggable}
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} 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' }}> <td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input <input
type="checkbox" type="checkbox"
checked={selectedRows.has(globalIndex)} checked={selectedIds.has(_getRowId(row))}
onChange={() => handleRowSelect(globalIndex)} onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)} disabled={isRowSelectable && !isRowSelectable(row)}
title={ title={
@ -2184,7 +2276,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return ( return (
<tr <tr
key={index} 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)} onClick={() => onRowClick?.(row, index)}
draggable={rowDraggable} draggable={rowDraggable}
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} 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' }}> <td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input <input
type="checkbox" type="checkbox"
checked={selectedRows.has(index)} checked={selectedIds.has(_getRowId(row))}
onChange={() => handleRowSelect(index)} onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)} disabled={isRowSelectable && !isRowSelectable(row)}
title={ 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) * Get current user's feature instances (grouped by mandate)
*/ */
@ -495,6 +520,7 @@ export function useFeatureAccess() {
updateInstance, updateInstance,
deleteInstance, deleteInstance,
syncInstanceRoles, syncInstanceRoles,
syncInstanceWorkflows,
fetchMyFeatureInstances, fetchMyFeatureInstances,
fetchTemplateRoles, fetchTemplateRoles,
// Instance users management // Instance users management

View file

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

View file

@ -11,6 +11,7 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import demoStyles from './AdminDemoConfigPage.module.css'; import demoStyles from './AdminDemoConfigPage.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { useConfirm } from '../../hooks/useConfirm';
interface _DemoConfig { interface _DemoConfig {
code: string; code: string;
@ -28,6 +29,7 @@ interface _ActionResult {
export const AdminDemoConfigPage: React.FC = () => { export const AdminDemoConfigPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm();
const [configs, setConfigs] = useState<_DemoConfig[]>([]); const [configs, setConfigs] = useState<_DemoConfig[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [actionInProgress, setActionInProgress] = useState<string | null>(null); const [actionInProgress, setActionInProgress] = useState<string | null>(null);
@ -67,7 +69,11 @@ export const AdminDemoConfigPage: React.FC = () => {
const _handleRemove = async (code: string) => { const _handleRemove = async (code: string) => {
if (actionInProgress) return; 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); setActionInProgress(code);
setLastResult(null); setLastResult(null);
try { try {
@ -143,6 +149,8 @@ export const AdminDemoConfigPage: React.FC = () => {
))} ))}
</div> </div>
)} )}
<ConfirmDialog />
</div> </div>
); );
}; };

View file

@ -36,6 +36,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
updateInstance, updateInstance,
deleteInstance, deleteInstance,
syncInstanceRoles, syncInstanceRoles,
syncInstanceWorkflows,
} = useFeatureAccess(); } = useFeatureAccess();
const { fetchMandates } = useUserMandates(); const { fetchMandates } = useUserMandates();
@ -50,6 +51,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
const [editingInstance, setEditingInstance] = useState<FeatureInstance | null>(null); const [editingInstance, setEditingInstance] = useState<FeatureInstance | null>(null);
const [, setIsSubmitting] = useState(false); const [, setIsSubmitting] = useState(false);
const [syncingInstance, setSyncingInstance] = useState<string | null>(null); const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
const [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = useState<string | null>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]); const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Chatbot configuration state // 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 // Get mandate name
const getMandateName = (mandate: Mandate) => { const getMandateName = (mandate: Mandate) => {
return mandate.label || mandate.name || mandate.id; return mandate.label || mandate.name || mandate.id;
@ -444,7 +469,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
searchable={true} searchable={true}
filterable={true} filterable={true}
sortable={true} sortable={true}
selectable={false} selectable={true}
actionButtons={[ actionButtons={[
{ {
type: 'delete' as const, type: 'delete' as const,
@ -465,6 +490,14 @@ export const AdminFeatureAccessPage: React.FC = () => {
title: t('Rollen synchronisieren'), title: t('Rollen synchronisieren'),
loading: (row: FeatureInstance) => syncingInstance === row.id, loading: (row: FeatureInstance) => syncingInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled, 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={{ hookData={{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -455,7 +455,7 @@ export const BillingDataView: React.FC = () => {
if (crossFilters && Object.keys(crossFilters).length > 0) { if (crossFilters && Object.keys(crossFilters).length > 0) {
params.pagination = JSON.stringify({ filters: crossFilters }); 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 : []; return Array.isArray(resp.data) ? resp.data : [];
}, [_scopeParams]); }, [_scopeParams]);

View file

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

View file

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

View file

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

View file

@ -162,7 +162,7 @@ export const RealEstateProjectsView: React.FC = () => {
searchable={true} searchable={true}
filterable={true} filterable={true}
sortable={true} sortable={true}
selectable={false} selectable={true}
actionButtons={[ actionButtons={[
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten') }] : []), ...(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) }] : []), ...(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 api from '../../../api';
import styles from './TrusteeViews.module.css'; import styles from './TrusteeViews.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { FaUpload, FaTimes } from 'react-icons/fa';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tab definitions // Tab definitions
@ -90,6 +91,14 @@ export const TrusteeAnalyseView: React.FC = () => {
const pollTimerRef = useRef<number | null>(null); const pollTimerRef = useRef<number | null>(null);
const isPollingRef = useRef(false); const isPollingRef = useRef(false);
const [resultText, setResultText] = useState<string | null>(null);
const [resultDocuments, setResultDocuments] = useState<Array<{ id?: string; fileName?: string; mimeType?: string }>>([]);
const [budgetFileId, setBudgetFileId] = useState<string | null>(null);
const [budgetFileName, setBudgetFileName] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load workflows for this instance once // Load workflows for this instance once
useEffect(() => { useEffect(() => {
if (!instanceId) return; if (!instanceId) return;
@ -151,6 +160,12 @@ export const TrusteeAnalyseView: React.FC = () => {
if (running.length === 0 && completed.length === steps.length && steps.length > 0) { if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
setRunState('completed'); setRunState('completed');
_stopPolling(); _stopPolling();
const lastStep = [...steps].reverse().find((s) => s.status === 'completed' && s.output);
if (lastStep?.output) {
setResultText(lastStep.output.response || lastStep.output.context || null);
const docs = lastStep.output.documents || lastStep.output.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
}
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.')); showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
return; return;
} }
@ -177,6 +192,25 @@ export const TrusteeAnalyseView: React.FC = () => {
useEffect(() => () => { _stopPolling(); }, [_stopPolling]); useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
const _extractResults = useCallback((nodeOutputs: Record<string, any>) => {
const analyseOut = nodeOutputs?.analyse || nodeOutputs?.result;
if (!analyseOut) {
for (const key of Object.keys(nodeOutputs || {})) {
const v = nodeOutputs[key];
if (v && typeof v === 'object' && (v.response || v.documents)) {
setResultText(v.response || v.context || null);
const docs = v.documents || v.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
return;
}
}
return;
}
setResultText(analyseOut.response || analyseOut.context || null);
const docs = analyseOut.documents || analyseOut.documentList || [];
setResultDocuments(Array.isArray(docs) ? docs : []);
}, []);
// Reset run state when tab changes // Reset run state when tab changes
useEffect(() => { useEffect(() => {
_stopPolling(); _stopPolling();
@ -184,8 +218,36 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunId(null); setRunId(null);
setRunSummary(''); setRunSummary('');
setRunError(null); setRunError(null);
setResultText(null);
setResultDocuments([]);
}, [activeTab, _stopPolling]); }, [activeTab, _stopPolling]);
const _handleBudgetUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const 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 // Execute workflow
const _handleExecute = useCallback(async () => { const _handleExecute = useCallback(async () => {
const wf = _findWorkflow(activeTab); const wf = _findWorkflow(activeTab);
@ -193,11 +255,21 @@ export const TrusteeAnalyseView: React.FC = () => {
showError(t('Fehler'), t('Kein Workflow für diesen Tab gefunden.')); showError(t('Fehler'), t('Kein Workflow für diesen Tab gefunden.'));
return; return;
} }
if (activeTab === 'budget' && !budgetFileId) {
showError(t('Budget-Datei fehlt'), t('Bitte laden Sie zuerst die Budget-Excel-Datei hoch.'));
return;
}
setRunState('starting'); setRunState('starting');
setRunError(null); setRunError(null);
setRunSummary(t('Workflow wird gestartet…')); setRunSummary(t('Workflow wird gestartet…'));
setResultText(null);
setResultDocuments([]);
try { 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; const rid = res?.data?.runId;
if (rid) { if (rid) {
setRunId(rid); setRunId(rid);
@ -206,6 +278,9 @@ export const TrusteeAnalyseView: React.FC = () => {
} else if (res?.data?.success) { } else if (res?.data?.success) {
setRunState('completed'); setRunState('completed');
setRunSummary(t('Workflow synchron abgeschlossen.')); setRunSummary(t('Workflow synchron abgeschlossen.'));
if (res.data.nodeOutputs) {
_extractResults(res.data.nodeOutputs);
}
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.')); showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
} else { } else {
throw new Error(res?.data?.error || t('Unerwartete Antwort')); throw new Error(res?.data?.error || t('Unerwartete Antwort'));
@ -216,7 +291,7 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg)); setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
showError(t('Fehler'), 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 currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
const currentWorkflow = _findWorkflow(activeTab); const currentWorkflow = _findWorkflow(activeTab);
@ -275,10 +350,56 @@ export const TrusteeAnalyseView: React.FC = () => {
</div> </div>
</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 <button
className={styles.primaryButton} className={styles.primaryButton}
onClick={_handleExecute} onClick={_handleExecute}
disabled={runState === 'starting' || runState === 'running'} disabled={runState === 'starting' || runState === 'running' || (activeTab === 'budget' && !budgetFileId)}
style={{ alignSelf: 'flex-start' }} style={{ alignSelf: 'flex-start' }}
> >
{runState === 'starting' || runState === 'running' {runState === 'starting' || runState === 'running'
@ -300,6 +421,61 @@ export const TrusteeAnalyseView: React.FC = () => {
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>} {runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
</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> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

@ -511,7 +511,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onChange={_handleChange} onChange={_handleChange}
onKeyDown={_handleKeyDown} onKeyDown={_handleKeyDown}
onPaste={_handlePaste} 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} disabled={isProcessing}
style={{ style={{
flex: 1, flex: 1,