From e6d28c436b3b88de58b269a5f8861dc26b68f97e Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 21:34:58 +0100 Subject: [PATCH] streamlined formgeneratortable and sort/filter globally --- src/api/automationApi.ts | 29 +- .../FormGeneratorControls.tsx | 6 +- .../FormGeneratorTable.module.css | 1 + .../FormGeneratorTable/FormGeneratorTable.tsx | 377 +++++++++++++----- src/config/pageRegistry.tsx | 2 + src/hooks/useAdminSubscriptions.ts | 84 ++++ src/hooks/useAutomations.ts | 17 +- src/pages/admin/AdminAutomationEventsPage.tsx | 29 +- src/pages/admin/AdminFeatureRolesPage.tsx | 32 +- src/pages/admin/AdminInvitationsPage.tsx | 2 +- src/pages/basedata/FilesPage.tsx | 2 + src/pages/basedata/PromptsPage.tsx | 103 ++--- src/pages/billing/AdminSubscriptionsPage.tsx | 85 ++-- src/pages/billing/BillingAdmin.tsx | 34 +- src/pages/billing/BillingDataView.tsx | 17 +- .../automation/AutomationTemplatesView.tsx | 4 +- .../realestate/RealEstateParcelsView.tsx | 24 +- .../realestate/RealEstateProjectsView.tsx | 22 +- .../views/trustee/TrusteeDocumentsView.tsx | 27 +- .../trustee/TrusteePositionDocumentsView.tsx | 27 +- .../views/trustee/TrusteePositionsView.tsx | 27 +- 21 files changed, 563 insertions(+), 388 deletions(-) create mode 100644 src/hooks/useAdminSubscriptions.ts diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index 38d560c..80d44cd 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -254,17 +254,26 @@ export async function fetchAutomationAttributes( * Endpoint: GET /api/automation-templates */ export async function fetchAutomationTemplates( - request: ApiRequestFunction -): Promise { - const data = await request({ - url: '/api/automation-templates', - method: 'get' - }); - - if (data?.items && Array.isArray(data.items)) { - return data.items; + request: ApiRequestFunction, + params?: any +): Promise { + const requestParams: Record = {}; + if (params && typeof params === 'object') { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } } - return Array.isArray(data) ? data : []; + return await request({ + url: '/api/automation-templates', + method: 'get', + params: requestParams, + }); } /** diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 11f33b4..24bbbae 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -100,11 +100,10 @@ export function FormGeneratorControls({ onPageChange, onPageSizeChange, supportsBackendPagination = false, - hookData: _hookData, // Reserved for future use + hookData, onCsvExport, csvExporting = false }: FormGeneratorControlsProps) { - void _hookData; // Suppress unused variable warning const { t } = useLanguage(); // Check if all items are selected @@ -290,9 +289,8 @@ export function FormGeneratorControls({ »» - {/* Total items count - always show actual displayed data length */} - ({loading ? '...' : displayData.length.toString()} {t('formgen.pagination.items', 'items')}) + ({loading ? '...' : (hookData?.pagination?.totalItems ?? displayData.length).toString()} {t('formgen.pagination.items', 'items')}) )} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 6e4a873..cd30431 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -56,6 +56,7 @@ position: relative; overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */ overflow-y: auto; + scrollbar-gutter: stable; background: var(--color-bg); /* Fill remaining space but constrain to available height */ flex: 1 1 0; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index f97d6d8..351a59b 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -1,3 +1,58 @@ +/** + * FormGeneratorTable — Backend-driven data table. + * + * ARCHITECTURE: + * This table does NO client-side filtering, sorting, or pagination. + * All data processing is delegated to the backend via hookData.refetch(). + * The `data` prop is rendered as-is (displayData = data). + * + * REQUIRED CONTRACT for interactive features (search, filter, sort, pagination): + * + * hookData={{ + * refetch, // (params?: PaginationParams) => Promise + * // Called on every search/filter/sort/page change. + * // Must fetch from backend with pagination query param + * // and update the data + pagination states. + * pagination, // { currentPage, pageSize, totalItems, totalPages } | null + * // Drives pagination controls. Comes from backend response. + * fetchFilterValues, // (columnKey: string) => Promise (Optional) + * // If provided, called when a filter dropdown opens. + * // If NOT provided but apiEndpoint is set, the table + * // auto-fetches from `{apiEndpoint}/filter-values?column=xxx`. + * }} + * + * Without hookData.refetch, interactive controls (sort, filter, search, + * pagination) are inert — the table renders data but actions have no effect. + * + * FILTER VALUES (autofilter): + * 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} + * 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. + * + * BACKEND RESPONSE FORMAT (for refetch): + * { items: T[], pagination: PaginationMetadata | null } + * + * BACKEND RESPONSE FORMAT (for filter-values): + * string[] + * + * EXAMPLE (minimal integration): + * + * const { data, pagination, loading, refetch } = useMyEntityHook(); + * + * + * + * See useOrgUsers / AdminUsersPage for a full reference implementation. + */ import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import type { IconType } from 'react-icons'; import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -175,6 +230,67 @@ export interface FormGeneratorTableProps { onRowDragStart?: (e: React.DragEvent, row: T) => void; } +const _FILTER_PAGE_SIZE = 100; + +/** + * Renders a scrollable list of filter values with IntersectionObserver-based lazy loading. + * Shows _FILTER_PAGE_SIZE items initially, loads more as the user scrolls. + */ +function FilterValuesList({ + columnKey, + allValues, + activeFilter, + onSelect, +}: { + columnKey: string; + allValues: string[]; + activeFilter: any; + onSelect: (value: string) => void; +}) { + const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE); + const sentinelRef = useRef(null); + + useEffect(() => { + setDisplayCount(_FILTER_PAGE_SIZE); + }, [columnKey, allValues.length]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || displayCount >= allValues.length) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, allValues.length)); + } + }, + { threshold: 0.1 } + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [displayCount, allValues.length]); + + const visibleValues = allValues.slice(0, displayCount); + + return ( + <> + {visibleValues.map(value => ( +
onSelect(value)} + title={value} + > + {value.length > 30 ? value.substring(0, 30) + '...' : value} +
+ ))} + {displayCount < allValues.length && ( +
+ )} + + ); +} + export function FormGeneratorTable>({ data, columns: providedColumns, @@ -294,8 +410,11 @@ export function FormGeneratorTable>({ useEffect(() => { const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 300); // 300ms debounce + setDebouncedSearchTerm(prev => { + if (prev !== searchTerm) setCurrentPage(1); + return searchTerm; + }); + }, 300); return () => clearTimeout(timer); }, [searchTerm]); @@ -718,21 +837,19 @@ export function FormGeneratorTable>({ const existingIndex = current.findIndex(sc => sc.key === key); if (existingIndex === -1) { - // Column not in sort list → add as ascending (lowest priority) return [...current, { key, direction: 'asc' }]; } const existing = current[existingIndex]; if (existing.direction === 'asc') { - // Ascending → change to descending (keep same position) const newConfigs = [...current]; newConfigs[existingIndex] = { key, direction: 'desc' }; return newConfigs; } - // Descending → remove from sort list return current.filter(sc => sc.key !== key); }); + setCurrentPage(1); }; // Get sort info for a column (returns { direction, position } or null) @@ -743,7 +860,7 @@ export function FormGeneratorTable>({ }, [sortConfigs]); // Handle filtering - const handleFilter = (key: string, value: any) => { + const handleFilter = (key: string, value: any, keepOpen = false) => { setFilters(prev => { const newFilters = { ...prev }; if (value === undefined || value === '' || value === null) { @@ -753,8 +870,10 @@ export function FormGeneratorTable>({ } return newFilters; }); - setCurrentPage(1); // Reset to first page when filtering - setOpenFilterColumn(null); // Close filter dropdown + setCurrentPage(1); + if (!keepOpen) { + setOpenFilterColumn(null); + } }; // Handle filter input focus @@ -782,22 +901,17 @@ export function FormGeneratorTable>({ }, [filters]); // Track which filter columns show all values (expanded beyond initial 100) - const [expandedFilterColumns, setExpandedFilterColumns] = useState>(new Set()); - // Async-loaded filter values per column (from backend via hookData.fetchFilterValues) const [asyncFilterValues, setAsyncFilterValues] = useState>({}); const [filterValuesLoading, setFilterValuesLoading] = useState>({}); - const _toggleFilterExpand = useCallback((columnKey: string) => { - setExpandedFilterColumns(prev => { - const next = new Set(prev); - if (next.has(columnKey)) { - next.delete(columnKey); - } else { - next.add(columnKey); - } - return next; - }); - }, []); + // Invalidate cached filter values when filters change (cross-filtering) + const filtersRef = useRef(filters); + useEffect(() => { + if (filtersRef.current !== filters) { + filtersRef.current = filters; + setAsyncFilterValues({}); + } + }, [filters]); // Load filter values on-demand when a filter dropdown is opened useEffect(() => { @@ -811,58 +925,61 @@ export function FormGeneratorTable>({ // Skip if already loaded or currently loading if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return; - // If the hook provides fetchFilterValues, use it (backend distinct query) - if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') { - setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: true })); - hookData.fetchFilterValues(openFilterColumn).then((values: string[]) => { - setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: values })); - }).catch(() => { - // On error, fall back to current page data (set empty to prevent re-fetch) - setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: [] })); - }).finally(() => { - setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: false })); - }); - } - }, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData]); + const _fetchValues = async (columnKey: string) => { + setFilterValuesLoading(prev => ({ ...prev, [columnKey]: true })); + try { + let values: string[]; + if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') { + values = await hookData.fetchFilterValues(columnKey); + } else if (apiEndpoint && supportsBackendPagination) { + const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint; + const params: Record = { column: columnKey }; + if (Object.keys(filters).length > 0) { + params.pagination = JSON.stringify({ filters }); + } + const response = await api.get(`${endpoint}/filter-values`, { params }); + values = Array.isArray(response.data) ? response.data : []; + } else { + values = []; + } + setAsyncFilterValues(prev => ({ ...prev, [columnKey]: values })); + } catch { + setAsyncFilterValues(prev => ({ ...prev, [columnKey]: [] })); + } finally { + setFilterValuesLoading(prev => ({ ...prev, [columnKey]: false })); + } + }; + + _fetchValues(openFilterColumn); + }, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData, apiEndpoint, supportsBackendPagination, filters]); // Get unique values for a column (for filter dropdown) - // Priority: 1) column.filterOptions (static enum) - // 2) asyncFilterValues (loaded from backend) - // 3) data (current page - fallback) + // Sources: 1) column.filterOptions (static enum) + // 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues) + // 3) data — ONLY when no backend pagination (data = full dataset) + // With backend pagination, data is a single page, so extracting filter + // values from it would be incomplete and misleading. const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => { const column = detectedColumns.find(c => c.key === columnKey); - // Static enum options defined in the column config if (column?.filterOptions && column.filterOptions.length > 0) { return column.filterOptions; } - // Values loaded asynchronously from the backend (all data, not just page) if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) { return asyncFilterValues[columnKey]; } - // Fallback: extract from current page data - const values = new Set(); - data.forEach(row => { - const value = row[columnKey]; - if (value !== undefined && value !== null && value !== '') { - if (typeof value === 'object' && !Array.isArray(value)) { - if (isTextMultilingual(value)) { - const text = value.en || Object.values(value)[0]; - if (text) values.add(String(text)); - } else { - values.add(JSON.stringify(value)); - } - } else if (typeof value === 'boolean') { - values.add(value ? 'true' : 'false'); - } else { - values.add(String(value)); - } - } - }); - return Array.from(values).sort(); - }, [data, detectedColumns, asyncFilterValues]); + if (!apiEndpoint && !hookData?.fetchFilterValues) { + 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) ` + + `or add filterOptions to the column config.` + ); + } + return []; + }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData]); // Close filter dropdown when clicking outside useEffect(() => { @@ -1131,7 +1248,7 @@ export function FormGeneratorTable>({ topScrollbar.removeEventListener('scroll', syncTopToContainer); tableContainer.removeEventListener('scroll', syncContainerToTop); }; - }, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change + }, [detectedColumns, columnWidths]); // ResizeObserver handles data-driven size changes // Track which cells are currently being updated (for loading state) const [updatingCells, setUpdatingCells] = useState>(new Set()); @@ -1828,54 +1945,104 @@ export function FormGeneratorTable>({ )}
- {/* "All" option to clear filter */} -
clearFilter(column.key)} - > - ({t('formgen.filter.all', 'All')}) -
- {/* Filter values - loaded from backend or static filterOptions */} - {filterValuesLoading[column.key] ? ( -
- {t('formgen.filter.loading', 'Lade Filterwerte...')} -
- ) : (() => { - const allValues = getUniqueValuesForColumn(column.key); - const isExpanded = expandedFilterColumns.has(column.key); - const displayLimit = isExpanded ? allValues.length : 100; - const visibleValues = allValues.slice(0, displayLimit); - const remaining = allValues.length - displayLimit; + {(() => { + const colType = column.type || 'text'; + const isBool = isCheckboxType(colType as AttributeType); + const isDate = isDateTimeType(colType as AttributeType); + + if (isBool) { + const currentVal = filters[column.key]; + return ( + <> +
clearFilter(column.key)} + > + ({t('formgen.filter.all', 'Alle')}) +
+
handleFilter(column.key, 'true')} + > + {t('formgen.filter.yes', 'Ja')} +
+
handleFilter(column.key, 'false')} + > + {t('formgen.filter.no', 'Nein')} +
+ + ); + } + + if (isDate) { + const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {}; + return ( +
+
clearFilter(column.key)} + > + ({t('formgen.filter.all', 'Alle')}) +
+ + { + const from = e.target.value; + const to = rangeVal.to || ''; + if (!from && !to) { + clearFilter(column.key); + } else { + handleFilter(column.key, { operator: 'between', value: { from, to } }, true); + } + }} + /> + + { + const to = e.target.value; + const from = rangeVal.from || ''; + if (!from && !to) { + clearFilter(column.key); + } else { + handleFilter(column.key, { operator: 'between', value: { from, to } }, true); + } + }} + /> +
+ ); + } return ( <> - {visibleValues.map(value => ( -
handleFilter(column.key, value)} - title={value} - > - {value.length > 30 ? value.substring(0, 30) + '...' : value} -
- ))} - {remaining > 0 && ( -
_toggleFilterExpand(column.key)} - style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }} - > - + {remaining} {t('formgen.filter.more', 'weitere anzeigen')} -
- )} - {isExpanded && allValues.length > 100 && ( -
_toggleFilterExpand(column.key)} - style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }} - > - {t('formgen.filter.less', 'Weniger anzeigen')} +
clearFilter(column.key)} + > + ({t('formgen.filter.all', 'Alle')}) +
+ {filterValuesLoading[column.key] ? ( +
+ {t('formgen.filter.loading', 'Lade Filterwerte...')}
+ ) : ( + handleFilter(column.key, value)} + /> )} ); diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 21a8b40..31922b5 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -22,6 +22,7 @@ import { FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase, FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList, + FaFileContract, } from 'react-icons/fa'; // ============================================================================= @@ -66,6 +67,7 @@ export const PAGE_ICONS: Record = { 'page.admin.user-access-overview': , 'page.admin.userAccessOverview': , 'page.admin.billing': , + 'page.admin.subscriptions': , 'page.admin.automationEvents': , 'page.admin.automation-events': , 'page.admin.logs': , diff --git a/src/hooks/useAdminSubscriptions.ts b/src/hooks/useAdminSubscriptions.ts new file mode 100644 index 0000000..02acd1f --- /dev/null +++ b/src/hooks/useAdminSubscriptions.ts @@ -0,0 +1,84 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; + +interface PaginationParams { + page?: number; + pageSize?: number; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; + filters?: Record; + search?: string; +} + +interface PaginationState { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + +const _STATUS_LABELS: Record = { + PENDING: 'Ausstehend', + SCHEDULED: 'Geplant', + TRIALING: 'Testphase', + ACTIVE: 'Aktiv', + PAST_DUE: 'Überfällig', + EXPIRED: 'Abgelaufen', +}; + +export function useAdminSubscriptions() { + const [subscriptions, setSubscriptions] = useState([]); + const [pagination, setPagination] = useState(null); + const { request, isLoading: loading, error } = useApiRequest(); + + const refetch = useCallback(async (params?: PaginationParams) => { + try { + const requestParams: Record = {}; + + if (params) { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } + + const data = await request({ + url: '/api/subscription/admin/all', + method: 'get', + params: requestParams, + }); + + if (data && typeof data === 'object' && 'items' in data) { + const items = Array.isArray(data.items) ? data.items : []; + setSubscriptions(items.map(_enrichRow)); + if (data.pagination) { + setPagination(data.pagination); + } + } else { + const items = Array.isArray(data) ? data : []; + setSubscriptions(items.map(_enrichRow)); + setPagination(null); + } + } catch { + setSubscriptions([]); + setPagination(null); + } + }, [request]); + + useEffect(() => { refetch(); }, [refetch]); + + return { data: subscriptions, pagination, loading, error, refetch }; +} + +function _enrichRow(row: any): any { + return { + ...row, + _rawStatus: row.status, + status: _STATUS_LABELS[row.status] || row.status, + }; +} diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index 69a6cbf..28897ac 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -472,22 +472,30 @@ export function useAutomationOperations() { export function useAutomationTemplates() { const [templates, setTemplates] = useState([]); const [attributes, setAttributes] = useState([]); + const [pagination, setPagination] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { request } = useApiRequest(); const { checkPermission } = usePermissions(); const [permissions, setPermissions] = useState(null); - const fetchTemplates = useCallback(async () => { + const fetchTemplates = useCallback(async (params?: any) => { setLoading(true); setError(null); try { - const data = await fetchTemplatesApi(request); - setTemplates(data); + const data = await fetchTemplatesApi(request, params); + if (data && typeof data === 'object' && 'items' in data) { + setTemplates(Array.isArray(data.items) ? data.items : []); + if (data.pagination) setPagination(data.pagination); + } else { + setTemplates(Array.isArray(data) ? data : []); + setPagination(null); + } } catch (e: any) { console.error('Error fetching templates:', e); setError(e.message || 'Failed to fetch templates'); setTemplates([]); + setPagination(null); } finally { setLoading(false); } @@ -555,11 +563,12 @@ export function useAutomationTemplates() { return { templates, - data: templates, // Alias for FormGenerator compatibility + data: templates, attributes, loading, error, permissions, + pagination, refetch, fetchTemplates, fetchAttributes, diff --git a/src/pages/admin/AdminAutomationEventsPage.tsx b/src/pages/admin/AdminAutomationEventsPage.tsx index dee1558..2433199 100644 --- a/src/pages/admin/AdminAutomationEventsPage.tsx +++ b/src/pages/admin/AdminAutomationEventsPage.tsx @@ -41,18 +41,37 @@ const _formatNextRun = (nextRunTime: string | null): string => { export const AdminAutomationEventsPage: React.FC = () => { const [events, setEvents] = useState([]); + const [pagination, setPagination] = useState(null); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); const [syncResult, setSyncResult] = useState(null); - const _fetchEvents = useCallback(async () => { + const _fetchEvents = useCallback(async (params?: any) => { try { setLoading(true); setError(null); - const response = await api.get('/api/admin/automation-events'); - // Map eventId to id for FormGeneratorTable compatibility - setEvents(response.data.map((e: any) => ({ ...e, id: e.eventId }))); + const requestParams: Record = {}; + if (params && typeof params === 'object') { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } + const response = await api.get('/api/admin/automation-events', { params: requestParams }); + const data = response.data; + if (data && typeof data === 'object' && 'items' in data) { + setEvents((data.items || []).map((e: any) => ({ ...e, id: e.eventId }))); + if (data.pagination) setPagination(data.pagination); + } else { + setEvents((Array.isArray(data) ? data : []).map((e: any) => ({ ...e, id: e.eventId }))); + setPagination(null); + } } catch (err: any) { setError(err.response?.data?.detail || 'Fehler beim Laden der Events'); } finally { @@ -196,6 +215,7 @@ export const AdminAutomationEventsPage: React.FC = () => { { hookData={{ handleDelete: _handleDelete, refetch: _fetchEvents, + pagination, }} emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren." /> diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index da1697d..cfd035c 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -73,8 +73,10 @@ export const AdminFeatureRolesPage: React.FC = () => { }, []); + const [pagination, setPagination] = useState(null); + // Load roles when feature changes - const fetchRoles = useCallback(async () => { + const fetchRoles = useCallback(async (params?: any) => { if (!selectedFeatureCode) { setRoles([]); return; @@ -83,15 +85,32 @@ export const AdminFeatureRolesPage: React.FC = () => { setLoading(true); setError(null); try { - const response = await api.get(`/api/features/templates/roles`, { - params: { featureCode: selectedFeatureCode } - }); - const roleList = response.data || []; - setRoles(Array.isArray(roleList) ? roleList : []); + const requestParams: Record = { featureCode: selectedFeatureCode }; + if (params && typeof params === 'object') { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } + const response = await api.get(`/api/features/templates/roles`, { params: requestParams }); + const data = response.data; + if (data && typeof data === 'object' && 'items' in data) { + setRoles(Array.isArray(data.items) ? data.items : []); + if (data.pagination) setPagination(data.pagination); + } else { + setRoles(Array.isArray(data) ? data : []); + setPagination(null); + } } catch (err: any) { console.error('Error loading feature roles:', err); setError('Fehler beim Laden der Feature-Rollen'); setRoles([]); + setPagination(null); } finally { setLoading(false); } @@ -383,6 +402,7 @@ export const AdminFeatureRolesPage: React.FC = () => { onDelete={handleDeleteRole} hookData={{ refetch: fetchRoles, + pagination, handleDelete: handleDeleteRole, }} emptyMessage="Keine Feature-Rollen gefunden" diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 6105266..8f42a62 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -379,7 +379,7 @@ export const AdminInvitationsPage: React.FC = () => { ]} hookData={{ handleDelete: handleDeleteInvitation, - refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }), + refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }), pagination, }} emptyMessage="Keine Einladungen gefunden" diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index bec6348..57eb64e 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -49,6 +49,7 @@ export const FilesPage: React.FC = () => { loading, error, refetch, + pagination, fetchFileById, updateFileOptimistically, } = useUserFiles(); @@ -479,6 +480,7 @@ export const FilesPage: React.FC = () => { onDeleteMultiple={handleDeleteMultiple} hookData={{ refetch: _tableRefetch, + pagination, permissions, handleDelete: handleFileDelete, handleInlineUpdate, diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index f3d4435..6a7e407 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -168,7 +168,7 @@ export const PromptsPage: React.FC = () => { } return ( -
+

Prompts

@@ -194,68 +194,45 @@ export const PromptsPage: React.FC = () => {
- {loading && (!prompts || prompts.length === 0) ? ( -
-
- Lade Prompts... -
- ) : !prompts || prompts.length === 0 ? ( -
- -

Keine Prompts vorhanden

-

- Erstellen Sie einen neuen Prompt, um loszulegen. -

- {canCreate && ( - - )} -
- ) : ( - deletingPrompts.has(row.id), - }] : []), - ]} - onDelete={handleDelete} - hookData={{ - refetch, - permissions, - pagination, - handleDelete: handlePromptDelete, - handleInlineUpdate, - updateOptimistically, - }} - emptyMessage="Keine Prompts gefunden" - /> - )} + deletingPrompts.has(row.id), + }] : []), + ]} + onDelete={handleDelete} + hookData={{ + refetch, + permissions, + pagination, + handleDelete: handlePromptDelete, + handleInlineUpdate, + updateOptimistically, + }} + emptyMessage="Keine Prompts gefunden" + />
{/* Create Modal */} diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx index 36450d0..7c16320 100644 --- a/src/pages/billing/AdminSubscriptionsPage.tsx +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -1,62 +1,31 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; -import { useApiRequest } from '../../hooks/useApi'; +import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions'; import { useConfirm } from '../../hooks/useConfirm'; import api from '../../api'; import styles from './Billing.module.css'; const _TERMINAL_STATUSES = new Set(['EXPIRED']); -const _STATUS_LABELS: Record = { - PENDING: 'Ausstehend', - SCHEDULED: 'Geplant', - TRIALING: 'Testphase', - ACTIVE: 'Aktiv', - PAST_DUE: 'Überfällig', - EXPIRED: 'Abgelaufen', -}; - const _COLUMNS: ColumnConfig[] = [ - { key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, width: 180 }, - { key: 'planTitle', label: 'Plan', type: 'text' as any, sortable: true, filterable: true, width: 180 }, - { key: 'status', label: 'Status', type: 'text' as any, sortable: true, filterable: true, width: 110 }, - { key: 'recurring', label: 'Wiederkehrend', type: 'boolean' as any, sortable: true, filterable: true, width: 120 }, - { key: 'activeUsers', label: 'User', type: 'number' as any, sortable: true, width: 70 }, - { key: 'activeInstances', label: 'Instanzen', type: 'number' as any, sortable: true, width: 90 }, - { key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number' as any, sortable: true, width: 140 }, - { key: 'startedAt', label: 'Gestartet', type: 'date' as any, sortable: true, width: 130 }, - { key: 'currentPeriodEnd', label: 'Periodenende', type: 'date' as any, sortable: true, width: 130 }, - { key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number' as any, sortable: true, width: 100 }, - { key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number' as any, sortable: true, width: 110 }, + { key: 'mandateName', label: 'Mandant', type: 'text', sortable: true, filterable: true, width: 180 }, + { key: 'planTitle', label: 'Plan', type: 'text', sortable: true, filterable: true, width: 180 }, + { key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 110 }, + { key: 'recurring', label: 'Wiederkehrend', type: 'boolean', sortable: true, filterable: true, width: 120 }, + { key: 'activeUsers', label: 'User', type: 'number', sortable: true, width: 70 }, + { key: 'activeInstances', label: 'Instanzen', type: 'number', sortable: true, width: 90 }, + { key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number', sortable: true, width: 140 }, + { key: 'startedAt', label: 'Gestartet', type: 'date', sortable: true, filterable: true, width: 130 }, + { key: 'currentPeriodEnd', label: 'Periodenende', type: 'date', sortable: true, filterable: true, width: 130 }, + { key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number', sortable: true, width: 100 }, + { key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number', sortable: true, width: 110 }, ]; const AdminSubscriptionsPage: React.FC = () => { const navigate = useNavigate(); - const { request } = useApiRequest(); const { confirm, ConfirmDialog } = useConfirm(); - const [subscriptions, setSubscriptions] = useState([]); - const [loading, setLoading] = useState(true); - - const _loadSubscriptions = useCallback(async () => { - setLoading(true); - try { - const data = await request({ url: '/api/subscription/admin/all', method: 'get' }); - const rows = (Array.isArray(data) ? data : []).map((row: any) => ({ - ...row, - status: _STATUS_LABELS[row.status] || row.status, - _rawStatus: row.status, - })); - setSubscriptions(rows); - } catch (err) { - console.error('Failed to load subscriptions:', err); - setSubscriptions([]); - } finally { - setLoading(false); - } - }, [request]); - - useEffect(() => { _loadSubscriptions(); }, [_loadSubscriptions]); + const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); const _handleForceCancel = useCallback(async (row: any) => { const ok = await confirm( @@ -67,15 +36,15 @@ const AdminSubscriptionsPage: React.FC = () => { try { await api.post('/api/subscription/force-cancel', { subscriptionId: row.id }); - await _loadSubscriptions(); + await refetch(); } catch (err) { console.error('Force cancel failed:', err); } - }, [confirm, _loadSubscriptions]); + }, [confirm, refetch]); return ( -
-
+
+

Subscription-Übersicht

Alle Abonnements aller Mandanten

- {loading ? ( -
Lade Subscriptions…
- ) : ( +
_handleForceCancel(row), - isVisible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), + onClick: (row: any) => _handleForceCancel(row), + visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), }, ]} emptyMessage="Keine Subscriptions vorhanden." /> - )} +
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index af26318..d16a47f 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -485,20 +485,40 @@ interface MandateTransactionsTabProps { const MandateTransactionsTab: React.FC = ({ mandateId }) => { const { request, isLoading: loading } = useApiRequest(); const [transactions, setTransactions] = useState([]); + const [pagination, setPagination] = useState(null); const [error, setError] = useState(null); - const _loadTransactions = useCallback(async () => { + const _loadTransactions = useCallback(async (params?: any) => { try { setError(null); + const requestParams: Record = {}; + if (params) { + const paginationObj: any = {}; + if (params.page !== undefined) paginationObj.page = params.page; + if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params.sort) paginationObj.sort = params.sort; + if (params.filters) paginationObj.filters = params.filters; + if (params.search) paginationObj.search = params.search; + if (Object.keys(paginationObj).length > 0) { + requestParams.pagination = JSON.stringify(paginationObj); + } + } const data = await request({ url: `/api/billing/admin/transactions/${mandateId}`, method: 'get', - params: { limit: 500 }, + params: requestParams, }); - setTransactions(Array.isArray(data) ? data : []); + if (data && typeof data === 'object' && 'items' in data) { + setTransactions(Array.isArray(data.items) ? data.items : []); + if (data.pagination) setPagination(data.pagination); + } else { + setTransactions(Array.isArray(data) ? data : []); + setPagination(null); + } } catch (err: any) { setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden'); setTransactions([]); + setPagination(null); } }, [request, mandateId]); @@ -506,10 +526,6 @@ const MandateTransactionsTab: React.FC = ({ mandate _loadTransactions(); }, [_loadTransactions]); - const hookData = useMemo(() => ({ - refetch: _loadTransactions, - }), [_loadTransactions]); - return (

@@ -528,8 +544,8 @@ const MandateTransactionsTab: React.FC = ({ mandate sortable={true} selectable={false} emptyMessage="Keine Transaktionen für diesen Mandanten" - onRefresh={_loadTransactions} - hookData={hookData} + onRefresh={() => _loadTransactions()} + hookData={{ refetch: _loadTransactions, pagination }} />

); diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 5c313ff..1d2571f 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -489,10 +489,16 @@ export const BillingDataView: React.FC = () => { setTransactionsError(null); const params: any = {}; - // Only serialize if it's a plain pagination object (not a React event or other non-serializable object) if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) { - const { page, pageSize, sortBy, sortDirection, search, filters } = paginationParams; - params.pagination = JSON.stringify({ page, pageSize, sortBy, sortDirection, search, filters }); + const pObj: any = {}; + if (paginationParams.page !== undefined) pObj.page = paginationParams.page; + if (paginationParams.pageSize !== undefined) pObj.pageSize = paginationParams.pageSize; + if (paginationParams.sort) pObj.sort = paginationParams.sort; + if (paginationParams.filters) pObj.filters = paginationParams.filters; + if (paginationParams.search) pObj.search = paginationParams.search; + if (Object.keys(pObj).length > 0) { + params.pagination = JSON.stringify(pObj); + } } const response = await api.get('/api/billing/view/users/transactions', { params }); @@ -526,10 +532,7 @@ export const BillingDataView: React.FC = () => { // hookData for FormGeneratorTable const transactionsHookData = useMemo(() => ({ refetch: _loadTransactions, - pagination: transactionsPagination ? { - totalPages: transactionsPagination.totalPages, - totalItems: transactionsPagination.totalItems, - } : undefined, + pagination: transactionsPagination || undefined, }), [_loadTransactions, transactionsPagination]); // Table column definitions diff --git a/src/pages/views/automation/AutomationTemplatesView.tsx b/src/pages/views/automation/AutomationTemplatesView.tsx index 43a2cdc..849a83f 100644 --- a/src/pages/views/automation/AutomationTemplatesView.tsx +++ b/src/pages/views/automation/AutomationTemplatesView.tsx @@ -23,6 +23,8 @@ export const AutomationTemplatesView: React.FC = () => { error, permissions, refetch, + fetchTemplates, + pagination, createTemplate, updateTemplate, deleteTemplate, @@ -176,7 +178,7 @@ export const AutomationTemplatesView: React.FC = () => { { type: 'delete' as const, title: 'Löschen', disabled: (row: any) => row.isSystem && !isSysAdmin ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin gelöscht werden' } : !canDelete ? { disabled: true, message: 'Keine Berechtigung' } : false }, ]} onDelete={(template) => handleDelete(template.id)} - hookData={{ refetch, handleDelete, attributes }} + hookData={{ refetch: fetchTemplates, pagination, handleDelete, attributes }} emptyMessage="Keine Vorlagen gefunden" /> )} diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 9042cb7..f9f2929 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -141,7 +141,7 @@ export const RealEstateParcelsView: React.FC = () => { } return ( -
+

Parzellen verwalten

@@ -163,26 +163,7 @@ export const RealEstateParcelsView: React.FC = () => {
- {loading && (!parcels || parcels.length === 0) ? ( -
-
- Lade Parzellen... -
- ) : !parcels || parcels.length === 0 ? ( -
- -

Keine Parzellen vorhanden

-

- Erstellen Sie eine neue Parzelle, um zu beginnen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Parzellen gefunden" /> - )}
{(editingParcel || isCreateMode) && ( diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 41124f9..143ee25 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -131,7 +131,7 @@ export const RealEstateProjectsView: React.FC = () => { } return ( -
+

Projekte verwalten

@@ -149,24 +149,7 @@ export const RealEstateProjectsView: React.FC = () => {
- {loading && (!projects || projects.length === 0) ? ( -
-
- Lade Projekte... -
- ) : !projects || projects.length === 0 ? ( -
- -

Keine Projekte vorhanden

-

Erstellen Sie ein neues Projekt, um zu beginnen.

- {canCreate && ( - - )} -
- ) : ( - { hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }} emptyMessage="Keine Projekte gefunden" /> - )}
{(editingProject || isCreateMode) && ( diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index fea0e85..aeb4703 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -178,7 +178,7 @@ export const TrusteeDocumentsView: React.FC = () => { } return ( -
+

Belege und Dokumente verwalten

@@ -203,29 +203,7 @@ export const TrusteeDocumentsView: React.FC = () => {
- {loading && (!documents || documents.length === 0) ? ( -
-
- Lade Dokumente... -
- ) : !documents || documents.length === 0 ? ( -
- -

Keine Dokumente vorhanden

-

- Erstellen Sie ein neues Dokument, um zu beginnen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Dokumente gefunden" /> - )}
{/* Create/Edit Modal */} diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index 85c293b..cc008ca 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -146,7 +146,7 @@ export const TrusteePositionDocumentsView: React.FC = () => { } return ( -
+

Belege mit Buchungspositionen verknüpfen

@@ -171,29 +171,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
- {loading && (!links || links.length === 0) ? ( -
-
- Lade Verknüpfungen... -
- ) : !links || links.length === 0 ? ( -
- -

Keine Verknüpfungen vorhanden

-

- Verknüpfen Sie Belege mit Buchungspositionen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Verknüpfungen gefunden" /> - )}
{/* Create Modal */} diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 4de9f93..1c2d1a1 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -412,7 +412,7 @@ export const TrusteePositionsView: React.FC = () => { } return ( -
+

Buchungspositionen verwalten

@@ -437,29 +437,7 @@ export const TrusteePositionsView: React.FC = () => {
- {loading && (!positions || positions.length === 0) ? ( -
-
- Lade Positionen... -
- ) : !positions || positions.length === 0 ? ( -
- -

Keine Positionen vorhanden

-

- Erstellen Sie eine neue Position, um zu beginnen. -

- {canCreate && ( - - )} -
- ) : ( - { }} emptyMessage="Keine Positionen gefunden" /> - )}
{/* Create/Edit Modal */}