fixed formgenerator , trustee, sort and filter
This commit is contained in:
parent
5805c547eb
commit
b76947d613
27 changed files with 557 additions and 156 deletions
|
|
@ -177,6 +177,9 @@ function App() {
|
|||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||
|
||||
{/* Neutralization Feature Views */}
|
||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||
|
||||
{/* CommCoach Feature Views */}
|
||||
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
||||
|
|
|
|||
|
|
@ -390,6 +390,17 @@ export async function deleteWorkflow(
|
|||
});
|
||||
}
|
||||
|
||||
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
|
||||
export async function deleteSystemWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
workflowId: string,
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/system/workflow-runs/workflows/${workflowId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export interface Automation2Run {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
|
|
|
|||
|
|
@ -40,11 +40,11 @@ export interface FormGeneratorControlsProps {
|
|||
onDeleteSingle?: () => void;
|
||||
onDeleteMultiple?: () => void;
|
||||
|
||||
// Optional batch actions (e.g. "Sync to Accounting") – shown when items are selected
|
||||
batchActions?: {
|
||||
label: string;
|
||||
onClick: () => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: IconType;
|
||||
}[];
|
||||
|
||||
|
|
@ -71,9 +71,12 @@ export interface FormGeneratorControlsProps {
|
|||
onPageSizeChange?: (pageSize: number) => void;
|
||||
supportsBackendPagination?: boolean;
|
||||
hookData?: any;
|
||||
// CSV Export
|
||||
onCsvExport?: () => void;
|
||||
csvExporting?: boolean;
|
||||
totalFilteredItems?: number;
|
||||
onSelectAllFiltered?: () => void;
|
||||
selectAllFilteredActive?: boolean;
|
||||
selectAllFilteredLoading?: boolean;
|
||||
}
|
||||
|
||||
export function FormGeneratorControls({
|
||||
|
|
@ -102,7 +105,11 @@ export function FormGeneratorControls({
|
|||
supportsBackendPagination = false,
|
||||
hookData,
|
||||
onCsvExport,
|
||||
csvExporting = false
|
||||
csvExporting = false,
|
||||
totalFilteredItems,
|
||||
onSelectAllFiltered,
|
||||
selectAllFilteredActive = false,
|
||||
selectAllFilteredLoading = false,
|
||||
}: FormGeneratorControlsProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
|
|
@ -143,11 +150,32 @@ export function FormGeneratorControls({
|
|||
variant="secondary"
|
||||
size="sm"
|
||||
icon={action.icon}
|
||||
disabled={action.loading}
|
||||
disabled={action.loading || action.disabled}
|
||||
>
|
||||
{action.loading ? t('Laden...') : action.label}
|
||||
</Button>
|
||||
))}
|
||||
{onSelectAllFiltered && totalFilteredItems !== undefined && totalFilteredItems > selectedCount && !selectAllFilteredActive && (
|
||||
<Button
|
||||
onClick={onSelectAllFiltered}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={selectAllFilteredLoading}
|
||||
>
|
||||
{selectAllFilteredLoading
|
||||
? t('Laden...')
|
||||
: t('Alle {count} Einträge auswählen', { count: totalFilteredItems.toString() })}
|
||||
</Button>
|
||||
)}
|
||||
{selectAllFilteredActive && (
|
||||
<Button
|
||||
onClick={onSelectAllFiltered}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{t('Auswahl aufheben')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
* fetchFilterValues, // (columnKey: string) => Promise<string[]> (Optional)
|
||||
* // If provided, called when a filter dropdown opens.
|
||||
* // If NOT provided but apiEndpoint is set, the table
|
||||
* // auto-fetches from `{apiEndpoint}/filter-values?column=xxx`.
|
||||
* // auto-fetches from `{apiEndpoint}?mode=filterValues&column=xxx`.
|
||||
* }}
|
||||
*
|
||||
* Without hookData.refetch, interactive controls (sort, filter, search,
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
* When a filterable column's dropdown opens, distinct values are loaded from:
|
||||
* 1. column.filterOptions (static enum — used as-is, no backend call)
|
||||
* 2. hookData.fetchFilterValues(columnKey) if provided
|
||||
* 3. GET {apiEndpoint}/filter-values?column=xxx&pagination={currentFilters}
|
||||
* 3. GET {apiEndpoint}?mode=filterValues&column=xxx&pagination={currentFilters}
|
||||
* Cross-filtering is supported: changing a filter invalidates the cache,
|
||||
* so re-opening another column's dropdown re-fetches with current filters.
|
||||
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker.
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
* BACKEND RESPONSE FORMAT (for refetch):
|
||||
* { items: T[], pagination: PaginationMetadata | null }
|
||||
*
|
||||
* BACKEND RESPONSE FORMAT (for filter-values):
|
||||
* BACKEND RESPONSE FORMAT (for mode=filterValues):
|
||||
* string[]
|
||||
*
|
||||
* EXAMPLE (minimal integration):
|
||||
|
|
@ -108,6 +108,7 @@ export interface ColumnConfig {
|
|||
searchable?: boolean;
|
||||
formatter?: (value: any, row: any) => React.ReactNode;
|
||||
filterOptions?: string[]; // For enum/select filters
|
||||
filterLabelResolver?: (value: string) => string; // Map filter value to display label in dropdown
|
||||
cellClassName?: (value: any, row: any) => string; // For custom cell styling
|
||||
fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/")
|
||||
fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel")
|
||||
|
|
@ -127,6 +128,7 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
showPageSizeSelector?: boolean;
|
||||
onRowClick?: (row: T, index: number) => void;
|
||||
onRowSelect?: (selectedRows: T[]) => void;
|
||||
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||
selectable?: boolean;
|
||||
isRowSelectable?: (row: T) => boolean;
|
||||
loading?: boolean;
|
||||
|
|
@ -176,6 +178,7 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
onClick: (rows: T[]) => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
icon?: IconType;
|
||||
isApplicable?: (row: T) => boolean;
|
||||
}[];
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
|
|
@ -193,6 +196,7 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
groupDefaultExpanded?: boolean;
|
||||
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
|
||||
initialSearchTerm?: string;
|
||||
initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>;
|
||||
rowDraggable?: boolean;
|
||||
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
||||
}
|
||||
|
|
@ -217,32 +221,70 @@ function FilterValuesList({
|
|||
resolveLabel?: (value: string) => string;
|
||||
}) {
|
||||
const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayCount(_FILTER_PAGE_SIZE);
|
||||
setSearchTerm('');
|
||||
}, [columnKey, allValues.length]);
|
||||
|
||||
useEffect(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, [columnKey]);
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
if (!searchTerm.trim()) return allValues;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return allValues.filter(value => {
|
||||
const label = resolveLabel ? resolveLabel(value) : value;
|
||||
return label.toLowerCase().includes(term);
|
||||
});
|
||||
}, [allValues, searchTerm, resolveLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || displayCount >= allValues.length) return;
|
||||
if (!sentinel || displayCount >= filteredValues.length) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, allValues.length));
|
||||
setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, filteredValues.length));
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [displayCount, allValues.length]);
|
||||
}, [displayCount, filteredValues.length]);
|
||||
|
||||
const visibleValues = allValues.slice(0, displayCount);
|
||||
const visibleValues = filteredValues.slice(0, displayCount);
|
||||
const showSearch = allValues.length > 10;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && (
|
||||
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
||||
placeholder="Filter..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '3px 6px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid var(--border-color, #ccc)',
|
||||
borderRadius: '3px',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleValues.map(value => {
|
||||
const label = resolveLabel ? resolveLabel(value) : value;
|
||||
return (
|
||||
|
|
@ -256,9 +298,14 @@ function FilterValuesList({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{displayCount < allValues.length && (
|
||||
{displayCount < filteredValues.length && (
|
||||
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} />
|
||||
)}
|
||||
{showSearch && searchTerm && filteredValues.length === 0 && (
|
||||
<div style={{ padding: '6px 8px', fontSize: '12px', color: 'var(--text-secondary, #888)' }}>
|
||||
Keine Treffer
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -276,7 +323,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
showPageSizeSelector = true,
|
||||
onRowClick,
|
||||
onRowSelect,
|
||||
selectable = true, // Default to true for selection functionality
|
||||
onSelectionChange,
|
||||
selectable = true,
|
||||
isRowSelectable,
|
||||
loading = false,
|
||||
inlineEditable = true, // Enable inline editing by default
|
||||
|
|
@ -300,6 +348,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
groupDefaultExpanded = true,
|
||||
groupActions,
|
||||
initialSearchTerm = '',
|
||||
initialSort,
|
||||
rowDraggable = false,
|
||||
onRowDragStart,
|
||||
}: FormGeneratorTableProps<T>) {
|
||||
|
|
@ -341,12 +390,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
|
||||
// Multi-column sorting: array of sort configs in order of priority
|
||||
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>([]);
|
||||
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []);
|
||||
const [filters, setFilters] = useState<Record<string, any>>({});
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
// Actions column width - resizable, default based on number of buttons
|
||||
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [selectAllFilteredActive, setSelectAllFilteredActive] = useState(false);
|
||||
const [selectAllFilteredLoading, setSelectAllFilteredLoading] = useState(false);
|
||||
const _idField = idField || 'id';
|
||||
const _getRowId = useCallback((row: any): string => String(row?.[_idField] ?? ''), [_idField]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||||
|
|
@ -639,7 +692,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
let displayLabel = item.id; // Fallback to ID
|
||||
|
||||
// Use the EXPLICIT display field from Pydantic model (fkDisplayField)
|
||||
if (displayField && item[displayField] !== undefined) {
|
||||
if (displayField && item[displayField] != null && item[displayField] !== '') {
|
||||
displayLabel = convertToDisplayString(item[displayField], currentLanguage);
|
||||
} else {
|
||||
// Fallback: if no displayField specified, try common fields
|
||||
|
|
@ -862,8 +915,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
// Skip if column has static filterOptions (enum) – those are used directly
|
||||
if (column?.filterOptions && column.filterOptions.length > 0) return;
|
||||
|
||||
// FK columns: extract values from actual data instead of backend endpoint
|
||||
if (column?.fkSource) return;
|
||||
// FK columns with backend pagination: still fetch from backend (data is only one page)
|
||||
// FK columns without backend pagination: skip (data is the full dataset, extracted below)
|
||||
if (column?.fkSource && !supportsBackendPagination) return;
|
||||
|
||||
// Skip if already loaded or currently loading
|
||||
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
|
||||
|
|
@ -879,12 +933,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
});
|
||||
values = await hookData.fetchFilterValues(columnKey, crossFilters);
|
||||
} else if (apiEndpoint && supportsBackendPagination) {
|
||||
const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint;
|
||||
const params: Record<string, string> = { column: columnKey };
|
||||
const params: Record<string, string> = { mode: 'filterValues', column: columnKey };
|
||||
if (Object.keys(filters).length > 0) {
|
||||
params.pagination = JSON.stringify({ filters });
|
||||
}
|
||||
const response = await api.get(`${endpoint}/filter-values`, { params });
|
||||
const response = await api.get(apiEndpoint, { params });
|
||||
values = Array.isArray(response.data) ? response.data : [];
|
||||
} else {
|
||||
values = [];
|
||||
|
|
@ -913,8 +966,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return column.filterOptions;
|
||||
}
|
||||
|
||||
// FK columns: extract distinct values from actual data (Excel autofilter style)
|
||||
if (column?.fkSource) {
|
||||
// FK columns without backend pagination: extract from full local data
|
||||
if (column?.fkSource && !supportsBackendPagination) {
|
||||
const seen = new Set<string>();
|
||||
data.forEach(row => {
|
||||
const val = row[columnKey];
|
||||
|
|
@ -937,7 +990,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
console.warn(
|
||||
`FormGeneratorTable: Column "${columnKey}" is filterable ` +
|
||||
`but has no filterOptions, no hookData.fetchFilterValues, and no apiEndpoint. ` +
|
||||
`Filter dropdown will be empty. Provide apiEndpoint (auto-fetches /filter-values) ` +
|
||||
`Filter dropdown will be empty. Provide apiEndpoint (auto-fetches ?mode=filterValues) ` +
|
||||
`or add filterOptions to the column config.`
|
||||
);
|
||||
}
|
||||
|
|
@ -964,73 +1017,99 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
setOpenFilterColumn(prev => prev === columnKey ? null : columnKey);
|
||||
}, []);
|
||||
|
||||
// Handle row selection
|
||||
const handleRowSelect = (index: number) => {
|
||||
if (!selectable) return;
|
||||
const _notifySelection = useCallback((newIds: Set<string>) => {
|
||||
setSelectedIds(newIds);
|
||||
onSelectionChange?.(newIds);
|
||||
if (onRowSelect) {
|
||||
const rows = displayData.filter(row => newIds.has(_getRowId(row)));
|
||||
onRowSelect(rows);
|
||||
}
|
||||
}, [displayData, onRowSelect, onSelectionChange, _getRowId]);
|
||||
|
||||
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;
|
||||
|
||||
const newSelected = new Set(selectedRows);
|
||||
if (newSelected.has(index)) {
|
||||
newSelected.delete(index);
|
||||
const rowId = _getRowId(row);
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (newSelected.has(rowId)) {
|
||||
newSelected.delete(rowId);
|
||||
} else {
|
||||
newSelected.add(index);
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
|
||||
if (onRowSelect) {
|
||||
const selectedData = Array.from(newSelected).map(i => displayData[i]);
|
||||
onRowSelect(selectedData);
|
||||
newSelected.add(rowId);
|
||||
}
|
||||
_notifySelection(newSelected);
|
||||
};
|
||||
|
||||
// Handle select all
|
||||
const handleSelectAll = () => {
|
||||
if (!selectable) return;
|
||||
|
||||
// Get only selectable rows
|
||||
const selectableIndices = displayData
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
||||
.map(({ index }) => index);
|
||||
const selectableRows = displayData.filter(row => !isRowSelectable || isRowSelectable(row));
|
||||
const selectableIds = selectableRows.map(row => _getRowId(row));
|
||||
|
||||
if (selectedRows.size === selectableIndices.length) {
|
||||
setSelectedRows(new Set());
|
||||
onRowSelect?.([]);
|
||||
if (selectedIds.size === selectableIds.length && selectableIds.every(id => selectedIds.has(id))) {
|
||||
_notifySelection(new Set());
|
||||
setSelectAllFilteredActive(false);
|
||||
} else {
|
||||
const allSelectableIndices = new Set(selectableIndices);
|
||||
setSelectedRows(allSelectableIndices);
|
||||
const selectableData = selectableIndices.map(i => displayData[i]);
|
||||
onRowSelect?.(selectableData);
|
||||
_notifySelection(new Set(selectableIds));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete single item
|
||||
const handleDeleteSingle = (row: T, index: number) => {
|
||||
const handleSelectAllFiltered = useCallback(async () => {
|
||||
if (selectAllFilteredActive) {
|
||||
_notifySelection(new Set());
|
||||
setSelectAllFilteredActive(false);
|
||||
return;
|
||||
}
|
||||
if (!apiEndpoint || !supportsBackendPagination) return;
|
||||
setSelectAllFilteredLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { mode: 'ids' };
|
||||
if (Object.keys(filters).length > 0 || searchTerm) {
|
||||
const paginationFilters: Record<string, any> = { ...filters };
|
||||
if (searchTerm) paginationFilters.search = searchTerm;
|
||||
params.pagination = JSON.stringify({ filters: paginationFilters });
|
||||
}
|
||||
const response = await api.get(apiEndpoint, { params });
|
||||
const allIds: string[] = Array.isArray(response.data) ? response.data : [];
|
||||
_notifySelection(new Set(allIds));
|
||||
setSelectAllFilteredActive(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch all filtered IDs:', e);
|
||||
} finally {
|
||||
setSelectAllFilteredLoading(false);
|
||||
}
|
||||
}, [selectAllFilteredActive, apiEndpoint, supportsBackendPagination, filters, searchTerm, _notifySelection]);
|
||||
|
||||
const handleDeleteSingle = (row: T, _index: number) => {
|
||||
if (onDelete) {
|
||||
onDelete(row);
|
||||
// Remove from selection if it was selected
|
||||
if (selectedRows.has(index)) {
|
||||
const newSelected = new Set(selectedRows);
|
||||
newSelected.delete(index);
|
||||
setSelectedRows(newSelected);
|
||||
if (onRowSelect) {
|
||||
const selectedData = Array.from(newSelected).map(i => displayData[i]);
|
||||
onRowSelect(selectedData);
|
||||
}
|
||||
const rowId = _getRowId(row);
|
||||
if (selectedIds.has(rowId)) {
|
||||
const newSelected = new Set(selectedIds);
|
||||
newSelected.delete(rowId);
|
||||
_notifySelection(newSelected);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete multiple items
|
||||
const handleDeleteMultiple = () => {
|
||||
if (onDeleteMultiple && selectedRows.size > 0) {
|
||||
const selectedData = Array.from(selectedRows).map(i => displayData[i]);
|
||||
if (onDeleteMultiple && selectedIds.size > 0) {
|
||||
const selectedData = displayData.filter(row => selectedIds.has(_getRowId(row)));
|
||||
onDeleteMultiple(selectedData);
|
||||
// Clear selection
|
||||
setSelectedRows(new Set());
|
||||
onRowSelect?.([]);
|
||||
_notifySelection(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1669,7 +1748,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return (
|
||||
<div className={`${styles.formGeneratorTable} ${className}`}>
|
||||
|
||||
{(searchable || (selectable && selectedRows.size > 0)) && (
|
||||
{(searchable || (selectable && selectedIds.size > 0)) && (
|
||||
<FormGeneratorControls
|
||||
fields={detectedColumns}
|
||||
searchTerm={searchTerm}
|
||||
|
|
@ -1680,12 +1759,12 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
onFilterChange={handleFilter}
|
||||
filterFocused={filterFocused}
|
||||
onFilterFocus={handleFilterFocus}
|
||||
selectedCount={selectedRows.size}
|
||||
selectedCount={selectedIds.size}
|
||||
displayData={displayData}
|
||||
onDeleteSingle={selectedRows.size === 1 && onDelete ? () => {
|
||||
const selectedIndex = Array.from(selectedRows)[0];
|
||||
const selectedRow = displayData[selectedIndex];
|
||||
handleDeleteSingle(selectedRow, selectedIndex);
|
||||
onDeleteSingle={selectedIds.size === 1 && onDelete ? () => {
|
||||
const selectedId = Array.from(selectedIds)[0];
|
||||
const selectedRow = displayData.find(row => _getRowId(row) === selectedId);
|
||||
if (selectedRow) handleDeleteSingle(selectedRow, 0);
|
||||
} : undefined}
|
||||
onDeleteMultiple={(() => {
|
||||
if (!onDeleteMultiple) return undefined;
|
||||
|
|
@ -1696,24 +1775,33 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
||||
.map(({ index }) => index);
|
||||
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
|
||||
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
|
||||
const allSelected = selectedIds.size === selectableIndices.length && selectableIndices.length > 0;
|
||||
return (selectedIds.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
|
||||
})()}
|
||||
batchActions={batchActions.length > 0 ? batchActions.map((ba) => ({
|
||||
label: ba.label,
|
||||
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 () => {
|
||||
const rows = Array.from(selectedRows).map((i) => displayData[i]);
|
||||
if (applicableCount === 0) return;
|
||||
try {
|
||||
await Promise.resolve(ba.onClick(rows));
|
||||
setSelectedRows(new Set());
|
||||
onRowSelect?.([]);
|
||||
await Promise.resolve(ba.onClick(applicableRows));
|
||||
_notifySelection(new Set());
|
||||
} catch {
|
||||
// Keep selection on error so user can retry
|
||||
}
|
||||
},
|
||||
})) : undefined}
|
||||
};
|
||||
}) : undefined}
|
||||
onRefresh={onRefresh}
|
||||
searchable={searchable}
|
||||
selectable={selectable}
|
||||
|
|
@ -1731,6 +1819,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
hookData={hookData}
|
||||
onCsvExport={apiEndpoint ? handleCsvExport : undefined}
|
||||
csvExporting={csvExporting}
|
||||
totalFilteredItems={hookData?.pagination?.totalItems}
|
||||
onSelectAllFiltered={apiEndpoint && supportsBackendPagination && selectable ? handleSelectAllFiltered : undefined}
|
||||
selectAllFilteredActive={selectAllFilteredActive}
|
||||
selectAllFilteredLoading={selectAllFilteredLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -1769,7 +1861,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => !isRowSelectable || isRowSelectable(row))
|
||||
.map(({ index }) => index);
|
||||
return selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
|
||||
return selectedIds.size === selectableIndices.length && selectableIndices.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
title={t('Alle Elemente auswählen')}
|
||||
|
|
@ -1969,7 +2061,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
allValues={getUniqueValuesForColumn(column.key)}
|
||||
activeFilter={filters[column.key]}
|
||||
onSelect={(value) => handleFilter(column.key, value)}
|
||||
resolveLabel={column.fkSource ? (val) => fkCache[column.fkSource!]?.[val] || val : undefined}
|
||||
resolveLabel={column.filterLabelResolver || (column.fkSource ? (val) => fkCache[column.fkSource!]?.[val] || val : undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -2068,7 +2160,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return (
|
||||
<tr
|
||||
key={`${groupKey}-row-${rowIndex}`}
|
||||
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||
className={`${styles.tr} ${selectedIds.has(_getRowId(row)) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||
onClick={() => onRowClick?.(row, globalIndex)}
|
||||
draggable={rowDraggable}
|
||||
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
|
||||
|
|
@ -2080,8 +2172,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(globalIndex)}
|
||||
onChange={() => handleRowSelect(globalIndex)}
|
||||
checked={selectedIds.has(_getRowId(row))}
|
||||
onChange={() => handleRowSelect(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={
|
||||
|
|
@ -2184,7 +2276,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||
className={`${styles.tr} ${selectedIds.has(_getRowId(row)) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||
onClick={() => onRowClick?.(row, index)}
|
||||
draggable={rowDraggable}
|
||||
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
|
||||
|
|
@ -2196,8 +2288,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRows.has(index)}
|
||||
onChange={() => handleRowSelect(index)}
|
||||
checked={selectedIds.has(_getRowId(row))}
|
||||
onChange={() => handleRowSelect(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useToast } from '../contexts/ToastContext';
|
|||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { formatUnixTimestamp } from '../utils/time';
|
||||
import { updateWorkflow, executeGraph, deleteWorkflow } from '../api/workflowApi';
|
||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
||||
import api from '../api';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import styles from './admin/Admin.module.css';
|
||||
|
|
@ -392,6 +392,7 @@ const _DashboardTab: React.FC = () => {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
||||
const lastPaginationParamsRef = useRef<any>(null);
|
||||
|
||||
const _loadMetrics = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -403,14 +404,19 @@ const _DashboardTab: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
const _loadRuns = useCallback(async (paginationParams?: any) => {
|
||||
if (paginationParams !== undefined) {
|
||||
lastPaginationParamsRef.current = paginationParams;
|
||||
}
|
||||
const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
|
||||
const pag = {
|
||||
page: paginationParams?.page || 1,
|
||||
pageSize: paginationParams?.pageSize || 25,
|
||||
...(paginationParams?.sort ? { sort: paginationParams.sort } : {}),
|
||||
...(paginationParams?.search ? { search: paginationParams.search } : {}),
|
||||
...(paginationParams?.filters ? { filters: paginationParams.filters } : {}),
|
||||
page: effectiveParams?.page || 1,
|
||||
pageSize: effectiveParams?.pageSize || 25,
|
||||
sort: effectiveParams?.sort || defaultSort,
|
||||
...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
|
||||
...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
|
||||
};
|
||||
const params: Record<string, any> = { pagination: JSON.stringify(pag) };
|
||||
const resp = await api.get('/api/system/workflow-runs', { params });
|
||||
|
|
@ -474,6 +480,15 @@ const _DashboardTab: React.FC = () => {
|
|||
}
|
||||
}, [showError, t]);
|
||||
|
||||
const _STATUS_LABELS: Record<string, string> = useMemo(() => ({
|
||||
running: t('Laufend'),
|
||||
completed: t('Abgeschlossen'),
|
||||
failed: t('Fehlgeschlagen'),
|
||||
cancelled: t('Abgebrochen'),
|
||||
paused: t('Pausiert'),
|
||||
stopped: t('Gestoppt'),
|
||||
}), [t]);
|
||||
|
||||
const _runColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'workflowLabel',
|
||||
|
|
@ -484,20 +499,24 @@ const _DashboardTab: React.FC = () => {
|
|||
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
||||
},
|
||||
{
|
||||
key: 'mandateLabel',
|
||||
key: 'mandateId',
|
||||
label: t('Mandant'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
fkSource: '/api/mandates/',
|
||||
fkDisplayField: 'label',
|
||||
},
|
||||
{
|
||||
key: 'instanceLabel',
|
||||
key: 'featureInstanceId',
|
||||
label: t('Instanz'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
fkSource: '/api/features/instances',
|
||||
fkDisplayField: 'label',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
|
|
@ -507,9 +526,10 @@ const _DashboardTab: React.FC = () => {
|
|||
sortable: true,
|
||||
filterable: true,
|
||||
filterOptions: ['running', 'completed', 'failed', 'cancelled', 'paused'],
|
||||
filterLabelResolver: (v: string) => _STATUS_LABELS[v] || v,
|
||||
formatter: (v: string) => (
|
||||
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600 }}>
|
||||
{v === 'completed' ? t('Abgeschlossen') : v === 'failed' ? t('Fehlgeschlagen') : v === 'running' ? t('Laufend') : v}
|
||||
{_STATUS_LABELS[v] || v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
@ -526,9 +546,10 @@ const _DashboardTab: React.FC = () => {
|
|||
label: t('Beendet'),
|
||||
type: 'number',
|
||||
width: 150,
|
||||
sortable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
], [t]);
|
||||
], [t, _STATUS_LABELS]);
|
||||
|
||||
const _hookData = useMemo(() => ({
|
||||
refetch: _loadRuns,
|
||||
|
|
@ -605,7 +626,8 @@ const _DashboardTab: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
||||
apiEndpoint="/api/system/workflow-runs"
|
||||
customActions={[
|
||||
{
|
||||
|
|
@ -649,20 +671,26 @@ const _WorkflowsTab: React.FC = () => {
|
|||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const lastPaginationParamsRef = useRef<any>(null);
|
||||
|
||||
const _load = useCallback(async (paginationParams?: any) => {
|
||||
if (paginationParams !== undefined) {
|
||||
lastPaginationParamsRef.current = paginationParams;
|
||||
}
|
||||
const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, any> = {};
|
||||
if (activeFilter === 'active') params.active = true;
|
||||
if (activeFilter === 'inactive') params.active = false;
|
||||
|
||||
const defaultSort = [{ field: 'createdAt', direction: 'desc' }];
|
||||
const pag = {
|
||||
page: paginationParams?.page || 1,
|
||||
pageSize: paginationParams?.pageSize || 25,
|
||||
...(paginationParams?.sort ? { sort: paginationParams.sort } : {}),
|
||||
...(paginationParams?.search ? { search: paginationParams.search } : {}),
|
||||
...(paginationParams?.filters ? { filters: paginationParams.filters } : {}),
|
||||
page: effectiveParams?.page || 1,
|
||||
pageSize: effectiveParams?.pageSize || 25,
|
||||
sort: effectiveParams?.sort || defaultSort,
|
||||
...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
|
||||
...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
|
||||
};
|
||||
params.pagination = JSON.stringify(pag);
|
||||
|
||||
|
|
@ -691,14 +719,13 @@ const _WorkflowsTab: React.FC = () => {
|
|||
|
||||
const _handleEdit = useCallback((row: SystemWorkflow) => {
|
||||
if (!row.mandateId || !row.featureInstanceId) return;
|
||||
navigate(`/mandates/${row.mandateId}/graphicalEditor/${row.featureInstanceId}/editor?workflowId=${row.id}`);
|
||||
const fc = (row as any).featureCode || 'graphicalEditor';
|
||||
navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
|
||||
}, [navigate]);
|
||||
|
||||
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
|
||||
const wf = workflows.find(w => w.id === workflowId);
|
||||
if (!wf?.featureInstanceId) return false;
|
||||
try {
|
||||
await deleteWorkflow(request, wf.featureInstanceId, workflowId);
|
||||
await deleteSystemWorkflow(request, workflowId);
|
||||
showSuccess(t('Workflow gelöscht'));
|
||||
await _load();
|
||||
return true;
|
||||
|
|
@ -706,7 +733,7 @@ const _WorkflowsTab: React.FC = () => {
|
|||
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
|
||||
return false;
|
||||
}
|
||||
}, [workflows, request, showSuccess, showError, _load, t]);
|
||||
}, [request, showSuccess, showError, _load, t]);
|
||||
|
||||
const _handleToggleActive = useCallback(async (row: SystemWorkflow) => {
|
||||
if (!row.featureInstanceId) return;
|
||||
|
|
@ -794,28 +821,22 @@ const _WorkflowsTab: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
const _columns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
|
||||
{ key: 'mandateLabel', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true },
|
||||
{ key: 'instanceLabel', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true },
|
||||
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true },
|
||||
{ key: 'mandateId', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/mandates/', fkDisplayField: 'label' },
|
||||
{ key: 'featureInstanceId', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/features/instances', fkDisplayField: 'label' },
|
||||
{
|
||||
key: 'active',
|
||||
label: t('Aktiv (Spalte)'),
|
||||
label: t('Aktiv'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
formatter: (value: boolean) =>
|
||||
value !== false
|
||||
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
|
||||
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
},
|
||||
{
|
||||
key: 'isRunning',
|
||||
label: t('läuft'),
|
||||
label: t('Läuft'),
|
||||
type: 'boolean',
|
||||
width: 80,
|
||||
formatter: (value: boolean) =>
|
||||
value
|
||||
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
|
||||
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
|
|
@ -827,7 +848,7 @@ const _WorkflowsTab: React.FC = () => {
|
|||
},
|
||||
{
|
||||
key: 'lastStartedAt',
|
||||
label: t('zuletzt gestartet'),
|
||||
label: t('Zuletzt gestartet'),
|
||||
type: 'number',
|
||||
width: 160,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
|
|
@ -884,7 +905,8 @@ const _WorkflowsTab: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
initialSort={[{ key: 'createdAt', direction: 'desc' }]}
|
||||
apiEndpoint="/api/system/workflow-runs/workflows"
|
||||
actionButtons={[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import api from '../../api';
|
|||
import styles from './Admin.module.css';
|
||||
import demoStyles from './AdminDemoConfigPage.module.css';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
|
||||
interface _DemoConfig {
|
||||
code: string;
|
||||
|
|
@ -28,6 +29,7 @@ interface _ActionResult {
|
|||
|
||||
export const AdminDemoConfigPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const [configs, setConfigs] = useState<_DemoConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
|
@ -67,7 +69,11 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
|
||||
const _handleRemove = async (code: string) => {
|
||||
if (actionInProgress) return;
|
||||
if (!window.confirm(t('Are you sure you want to remove all demo data for this configuration?'))) return;
|
||||
const ok = await confirm(
|
||||
t('Alle Demo-Daten für diese Konfiguration wirklich entfernen?'),
|
||||
{ confirmLabel: t('Entfernen'), cancelLabel: t('Abbrechen'), variant: 'danger' },
|
||||
);
|
||||
if (!ok) return;
|
||||
setActionInProgress(code);
|
||||
setLastResult(null);
|
||||
try {
|
||||
|
|
@ -143,6 +149,8 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
updateInstance,
|
||||
deleteInstance,
|
||||
syncInstanceRoles,
|
||||
syncInstanceWorkflows,
|
||||
} = useFeatureAccess();
|
||||
|
||||
const { fetchMandates } = useUserMandates();
|
||||
|
|
@ -50,6 +51,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
const [editingInstance, setEditingInstance] = useState<FeatureInstance | null>(null);
|
||||
const [, setIsSubmitting] = useState(false);
|
||||
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
|
||||
const [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = useState<string | null>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Chatbot configuration state
|
||||
|
|
@ -312,6 +314,29 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle sync workflows
|
||||
const _handleSyncWorkflows = async (instance: FeatureInstance) => {
|
||||
if (!selectedMandateId) return;
|
||||
setSyncingWorkflowsInstance(instance.id);
|
||||
try {
|
||||
const result = await syncInstanceWorkflows(selectedMandateId, instance.id);
|
||||
if (result.success && result.data) {
|
||||
showSuccess(
|
||||
t('Workflows synchronisiert'),
|
||||
t('Hinzugefügt: {added}\nÜbersprungen: {skipped}\nTotal Templates: {total}', {
|
||||
added: result.data.added,
|
||||
skipped: result.data.skipped,
|
||||
total: result.data.total,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
showError(t('Synchronisierung fehlgeschlagen'), result.error || t('Fehler beim Synchronisieren der Workflows'));
|
||||
}
|
||||
} finally {
|
||||
setSyncingWorkflowsInstance(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Get mandate name
|
||||
const getMandateName = (mandate: Mandate) => {
|
||||
return mandate.label || mandate.name || mandate.id;
|
||||
|
|
@ -444,7 +469,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'delete' as const,
|
||||
|
|
@ -465,6 +490,14 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
title: t('Rollen synchronisieren'),
|
||||
loading: (row: FeatureInstance) => syncingInstance === row.id,
|
||||
disabled: (row: FeatureInstance) => !row.enabled,
|
||||
},
|
||||
{
|
||||
id: 'syncWorkflows',
|
||||
icon: <FaSync />,
|
||||
onClick: _handleSyncWorkflows,
|
||||
title: t('Workflows synchronisieren'),
|
||||
loading: (row: FeatureInstance) => syncingWorkflowsInstance === row.id,
|
||||
disabled: (row: FeatureInstance) => !row.enabled,
|
||||
}
|
||||
]}
|
||||
hookData={{
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'delete' as const,
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export const PromptsPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'copy' as const,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit',
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit',
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
...(canUpdate
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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) }] : []),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useToast } from '../../../contexts/ToastContext';
|
|||
import api from '../../../api';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { FaUpload, FaTimes } from 'react-icons/fa';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab definitions
|
||||
|
|
@ -90,6 +91,14 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
const pollTimerRef = useRef<number | null>(null);
|
||||
const isPollingRef = useRef(false);
|
||||
|
||||
const [resultText, setResultText] = useState<string | null>(null);
|
||||
const [resultDocuments, setResultDocuments] = useState<Array<{ id?: string; fileName?: string; mimeType?: string }>>([]);
|
||||
|
||||
const [budgetFileId, setBudgetFileId] = useState<string | null>(null);
|
||||
const [budgetFileName, setBudgetFileName] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load workflows for this instance once
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -151,6 +160,12 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
|
||||
setRunState('completed');
|
||||
_stopPolling();
|
||||
const lastStep = [...steps].reverse().find((s) => s.status === 'completed' && s.output);
|
||||
if (lastStep?.output) {
|
||||
setResultText(lastStep.output.response || lastStep.output.context || null);
|
||||
const docs = lastStep.output.documents || lastStep.output.documentList || [];
|
||||
setResultDocuments(Array.isArray(docs) ? docs : []);
|
||||
}
|
||||
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -177,6 +192,25 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
|
||||
useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
|
||||
|
||||
const _extractResults = useCallback((nodeOutputs: Record<string, any>) => {
|
||||
const analyseOut = nodeOutputs?.analyse || nodeOutputs?.result;
|
||||
if (!analyseOut) {
|
||||
for (const key of Object.keys(nodeOutputs || {})) {
|
||||
const v = nodeOutputs[key];
|
||||
if (v && typeof v === 'object' && (v.response || v.documents)) {
|
||||
setResultText(v.response || v.context || null);
|
||||
const docs = v.documents || v.documentList || [];
|
||||
setResultDocuments(Array.isArray(docs) ? docs : []);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
setResultText(analyseOut.response || analyseOut.context || null);
|
||||
const docs = analyseOut.documents || analyseOut.documentList || [];
|
||||
setResultDocuments(Array.isArray(docs) ? docs : []);
|
||||
}, []);
|
||||
|
||||
// Reset run state when tab changes
|
||||
useEffect(() => {
|
||||
_stopPolling();
|
||||
|
|
@ -184,8 +218,36 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
setRunId(null);
|
||||
setRunSummary('');
|
||||
setRunError(null);
|
||||
setResultText(null);
|
||||
setResultDocuments([]);
|
||||
}, [activeTab, _stopPolling]);
|
||||
|
||||
const _handleBudgetUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !instanceId) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('featureInstanceId', instanceId);
|
||||
const res = await api.post('/api/files/upload', formData);
|
||||
const fileData = res.data?.file || res.data;
|
||||
setBudgetFileId(fileData.id);
|
||||
setBudgetFileName(fileData.fileName || file.name);
|
||||
showSuccess(t('Datei hochgeladen'), file.name);
|
||||
} catch (err: any) {
|
||||
showError(t('Upload fehlgeschlagen'), err.message || t('Datei konnte nicht hochgeladen werden.'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}, [instanceId, showSuccess, showError, t]);
|
||||
|
||||
const _handleRemoveBudgetFile = useCallback(() => {
|
||||
setBudgetFileId(null);
|
||||
setBudgetFileName(null);
|
||||
}, []);
|
||||
|
||||
// Execute workflow
|
||||
const _handleExecute = useCallback(async () => {
|
||||
const wf = _findWorkflow(activeTab);
|
||||
|
|
@ -193,11 +255,21 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
showError(t('Fehler'), t('Kein Workflow für diesen Tab gefunden.'));
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'budget' && !budgetFileId) {
|
||||
showError(t('Budget-Datei fehlt'), t('Bitte laden Sie zuerst die Budget-Excel-Datei hoch.'));
|
||||
return;
|
||||
}
|
||||
setRunState('starting');
|
||||
setRunError(null);
|
||||
setRunSummary(t('Workflow wird gestartet…'));
|
||||
setResultText(null);
|
||||
setResultDocuments([]);
|
||||
try {
|
||||
const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
|
||||
const executeBody: Record<string, any> = { workflowId: wf.id };
|
||||
if (activeTab === 'budget' && budgetFileId) {
|
||||
executeBody.payload = { documentList: [budgetFileId] };
|
||||
}
|
||||
const res = await api.post(`/api/workflows/${instanceId}/execute`, executeBody);
|
||||
const rid = res?.data?.runId;
|
||||
if (rid) {
|
||||
setRunId(rid);
|
||||
|
|
@ -206,6 +278,9 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
} else if (res?.data?.success) {
|
||||
setRunState('completed');
|
||||
setRunSummary(t('Workflow synchron abgeschlossen.'));
|
||||
if (res.data.nodeOutputs) {
|
||||
_extractResults(res.data.nodeOutputs);
|
||||
}
|
||||
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
||||
} else {
|
||||
throw new Error(res?.data?.error || t('Unerwartete Antwort'));
|
||||
|
|
@ -216,7 +291,7 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
}
|
||||
}, [activeTab, instanceId, _findWorkflow, showError, showSuccess, t]);
|
||||
}, [activeTab, instanceId, _findWorkflow, budgetFileId, showError, showSuccess, t]);
|
||||
|
||||
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
|
||||
const currentWorkflow = _findWorkflow(activeTab);
|
||||
|
|
@ -275,10 +350,56 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'budget' && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
border: '1px dashed var(--border-color, #ccc)',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--bg-secondary, #f9f9f9)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem' }}>
|
||||
{t('Budget-Excel hochladen')}
|
||||
</div>
|
||||
{budgetFileName ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem' }}>📄 {budgetFileName}</span>
|
||||
<button
|
||||
onClick={_handleRemoveBudgetFile}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-secondary, #666)', padding: '0.25rem',
|
||||
}}
|
||||
title={t('Datei entfernen')}
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.5rem 1rem', borderRadius: '6px', cursor: uploading ? 'wait' : 'pointer',
|
||||
border: '1px solid var(--border-color, #ccc)', background: 'var(--bg-primary, #fff)',
|
||||
fontSize: '0.875rem', color: 'var(--text-primary, #333)',
|
||||
}}>
|
||||
<FaUpload />
|
||||
{uploading ? t('Wird hochgeladen…') : t('Excel-Datei auswählen')}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={_handleBudgetUpload}
|
||||
disabled={uploading}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={_handleExecute}
|
||||
disabled={runState === 'starting' || runState === 'running'}
|
||||
disabled={runState === 'starting' || runState === 'running' || (activeTab === 'budget' && !budgetFileId)}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{runState === 'starting' || runState === 'running'
|
||||
|
|
@ -300,6 +421,61 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{runState === 'completed' && (resultText || resultDocuments.length > 0) && (
|
||||
<div style={{
|
||||
marginTop: '0.5rem',
|
||||
border: '1px solid var(--border-color, #e0e0e0)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{resultDocuments.length > 0 && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg-secondary, #f9f9f9)',
|
||||
borderBottom: resultText ? '1px solid var(--border-color, #e0e0e0)' : 'none',
|
||||
display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center',
|
||||
}}>
|
||||
<strong style={{ fontSize: '0.8125rem' }}>{t('Generierte Dokumente:')}</strong>
|
||||
{resultDocuments.map((doc, idx) => {
|
||||
const docId = doc.id || (typeof doc === 'string' ? doc : null);
|
||||
const docName = doc.fileName || `Dokument ${idx + 1}`;
|
||||
if (!docId) return null;
|
||||
return (
|
||||
<a
|
||||
key={docId}
|
||||
href={`${api.defaults.baseURL || ''}/api/files/${docId}/download`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
||||
background: 'var(--primary-color, #007bff)', color: '#fff',
|
||||
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
📄 {docName}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{resultText && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
}}>
|
||||
{resultText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
|
|
|
|||
|
|
@ -440,6 +440,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
||||
batchActions={[
|
||||
{
|
||||
label: t('Mit Buchhaltung synchronisieren'),
|
||||
|
|
|
|||
|
|
@ -511,7 +511,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
|||
onChange={_handleChange}
|
||||
onKeyDown={_handleKeyDown}
|
||||
onPaste={_handlePaste}
|
||||
placeholder={t('Geben Sie eine Nachricht ein, verwenden Sie {filename}')}
|
||||
placeholder={t('Geben Sie eine Nachricht ein, verwenden Sie @file für Dateien')}
|
||||
disabled={isProcessing}
|
||||
style={{
|
||||
flex: 1,
|
||||
|
|
|
|||
Loading…
Reference in a new issue