streamlined formgeneratortable and sort/filter globally
This commit is contained in:
parent
439fc3676f
commit
e6d28c436b
21 changed files with 563 additions and 388 deletions
|
|
@ -254,17 +254,26 @@ export async function fetchAutomationAttributes(
|
||||||
* Endpoint: GET /api/automation-templates
|
* Endpoint: GET /api/automation-templates
|
||||||
*/
|
*/
|
||||||
export async function fetchAutomationTemplates(
|
export async function fetchAutomationTemplates(
|
||||||
request: ApiRequestFunction
|
request: ApiRequestFunction,
|
||||||
): Promise<AutomationTemplate[]> {
|
params?: any
|
||||||
const data = await request({
|
): Promise<any> {
|
||||||
url: '/api/automation-templates',
|
const requestParams: Record<string, string> = {};
|
||||||
method: 'get'
|
if (params && typeof params === 'object') {
|
||||||
});
|
const paginationObj: any = {};
|
||||||
|
if (params.page !== undefined) paginationObj.page = params.page;
|
||||||
if (data?.items && Array.isArray(data.items)) {
|
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||||
return data.items;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -100,11 +100,10 @@ export function FormGeneratorControls({
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
supportsBackendPagination = false,
|
supportsBackendPagination = false,
|
||||||
hookData: _hookData, // Reserved for future use
|
hookData,
|
||||||
onCsvExport,
|
onCsvExport,
|
||||||
csvExporting = false
|
csvExporting = false
|
||||||
}: FormGeneratorControlsProps) {
|
}: FormGeneratorControlsProps) {
|
||||||
void _hookData; // Suppress unused variable warning
|
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
// Check if all items are selected
|
// Check if all items are selected
|
||||||
|
|
@ -290,9 +289,8 @@ export function FormGeneratorControls({
|
||||||
»»
|
»»
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Total items count - always show actual displayed data length */}
|
|
||||||
<span className={styles.paginationInfo}>
|
<span className={styles.paginationInfo}>
|
||||||
({loading ? '...' : displayData.length.toString()} {t('formgen.pagination.items', 'items')})
|
({loading ? '...' : (hookData?.pagination?.totalItems ?? displayData.length).toString()} {t('formgen.pagination.items', 'items')})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
|
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
/* Fill remaining space but constrain to available height */
|
/* Fill remaining space but constrain to available height */
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
|
|
|
||||||
|
|
@ -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<void>
|
||||||
|
* // 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<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`.
|
||||||
|
* }}
|
||||||
|
*
|
||||||
|
* 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();
|
||||||
|
*
|
||||||
|
* <FormGeneratorTable
|
||||||
|
* data={data}
|
||||||
|
* columns={columns}
|
||||||
|
* loading={loading}
|
||||||
|
* hookData={{ refetch, pagination }}
|
||||||
|
* apiEndpoint="/api/my-entity/" // enables CSV export + auto filter values
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* See useOrgUsers / AdminUsersPage for a full reference implementation.
|
||||||
|
*/
|
||||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
@ -175,6 +230,67 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, 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<HTMLDivElement>(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 => (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => onSelect(value)}
|
||||||
|
title={value}
|
||||||
|
>
|
||||||
|
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{displayCount < allValues.length && (
|
||||||
|
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FormGeneratorTable<T extends Record<string, any>>({
|
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
data,
|
data,
|
||||||
columns: providedColumns,
|
columns: providedColumns,
|
||||||
|
|
@ -294,8 +410,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedSearchTerm(searchTerm);
|
setDebouncedSearchTerm(prev => {
|
||||||
}, 300); // 300ms debounce
|
if (prev !== searchTerm) setCurrentPage(1);
|
||||||
|
return searchTerm;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
@ -718,21 +837,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const existingIndex = current.findIndex(sc => sc.key === key);
|
const existingIndex = current.findIndex(sc => sc.key === key);
|
||||||
|
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
// Column not in sort list → add as ascending (lowest priority)
|
|
||||||
return [...current, { key, direction: 'asc' }];
|
return [...current, { key, direction: 'asc' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = current[existingIndex];
|
const existing = current[existingIndex];
|
||||||
if (existing.direction === 'asc') {
|
if (existing.direction === 'asc') {
|
||||||
// Ascending → change to descending (keep same position)
|
|
||||||
const newConfigs = [...current];
|
const newConfigs = [...current];
|
||||||
newConfigs[existingIndex] = { key, direction: 'desc' };
|
newConfigs[existingIndex] = { key, direction: 'desc' };
|
||||||
return newConfigs;
|
return newConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descending → remove from sort list
|
|
||||||
return current.filter(sc => sc.key !== key);
|
return current.filter(sc => sc.key !== key);
|
||||||
});
|
});
|
||||||
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get sort info for a column (returns { direction, position } or null)
|
// Get sort info for a column (returns { direction, position } or null)
|
||||||
|
|
@ -743,7 +860,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}, [sortConfigs]);
|
}, [sortConfigs]);
|
||||||
|
|
||||||
// Handle filtering
|
// Handle filtering
|
||||||
const handleFilter = (key: string, value: any) => {
|
const handleFilter = (key: string, value: any, keepOpen = false) => {
|
||||||
setFilters(prev => {
|
setFilters(prev => {
|
||||||
const newFilters = { ...prev };
|
const newFilters = { ...prev };
|
||||||
if (value === undefined || value === '' || value === null) {
|
if (value === undefined || value === '' || value === null) {
|
||||||
|
|
@ -753,8 +870,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
return newFilters;
|
return newFilters;
|
||||||
});
|
});
|
||||||
setCurrentPage(1); // Reset to first page when filtering
|
setCurrentPage(1);
|
||||||
setOpenFilterColumn(null); // Close filter dropdown
|
if (!keepOpen) {
|
||||||
|
setOpenFilterColumn(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter input focus
|
// Handle filter input focus
|
||||||
|
|
@ -782,22 +901,17 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
// Track which filter columns show all values (expanded beyond initial 100)
|
// Track which filter columns show all values (expanded beyond initial 100)
|
||||||
const [expandedFilterColumns, setExpandedFilterColumns] = useState<Set<string>>(new Set());
|
|
||||||
// Async-loaded filter values per column (from backend via hookData.fetchFilterValues)
|
|
||||||
const [asyncFilterValues, setAsyncFilterValues] = useState<Record<string, string[]>>({});
|
const [asyncFilterValues, setAsyncFilterValues] = useState<Record<string, string[]>>({});
|
||||||
const [filterValuesLoading, setFilterValuesLoading] = useState<Record<string, boolean>>({});
|
const [filterValuesLoading, setFilterValuesLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const _toggleFilterExpand = useCallback((columnKey: string) => {
|
// Invalidate cached filter values when filters change (cross-filtering)
|
||||||
setExpandedFilterColumns(prev => {
|
const filtersRef = useRef(filters);
|
||||||
const next = new Set(prev);
|
useEffect(() => {
|
||||||
if (next.has(columnKey)) {
|
if (filtersRef.current !== filters) {
|
||||||
next.delete(columnKey);
|
filtersRef.current = filters;
|
||||||
} else {
|
setAsyncFilterValues({});
|
||||||
next.add(columnKey);
|
|
||||||
}
|
}
|
||||||
return next;
|
}, [filters]);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load filter values on-demand when a filter dropdown is opened
|
// Load filter values on-demand when a filter dropdown is opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -811,58 +925,61 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Skip if already loaded or currently loading
|
// Skip if already loaded or currently loading
|
||||||
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
|
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
|
||||||
|
|
||||||
// If the hook provides fetchFilterValues, use it (backend distinct query)
|
const _fetchValues = async (columnKey: string) => {
|
||||||
|
setFilterValuesLoading(prev => ({ ...prev, [columnKey]: true }));
|
||||||
|
try {
|
||||||
|
let values: string[];
|
||||||
if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') {
|
if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') {
|
||||||
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: true }));
|
values = await hookData.fetchFilterValues(columnKey);
|
||||||
hookData.fetchFilterValues(openFilterColumn).then((values: string[]) => {
|
} else if (apiEndpoint && supportsBackendPagination) {
|
||||||
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: values }));
|
const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint;
|
||||||
}).catch(() => {
|
const params: Record<string, string> = { column: columnKey };
|
||||||
// On error, fall back to current page data (set empty to prevent re-fetch)
|
if (Object.keys(filters).length > 0) {
|
||||||
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: [] }));
|
params.pagination = JSON.stringify({ filters });
|
||||||
}).finally(() => {
|
|
||||||
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: false }));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData]);
|
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)
|
// Get unique values for a column (for filter dropdown)
|
||||||
// Priority: 1) column.filterOptions (static enum)
|
// Sources: 1) column.filterOptions (static enum)
|
||||||
// 2) asyncFilterValues (loaded from backend)
|
// 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues)
|
||||||
// 3) data (current page - fallback)
|
// 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 getUniqueValuesForColumn = useCallback((columnKey: string): string[] => {
|
||||||
const column = detectedColumns.find(c => c.key === columnKey);
|
const column = detectedColumns.find(c => c.key === columnKey);
|
||||||
|
|
||||||
// Static enum options defined in the column config
|
|
||||||
if (column?.filterOptions && column.filterOptions.length > 0) {
|
if (column?.filterOptions && column.filterOptions.length > 0) {
|
||||||
return column.filterOptions;
|
return column.filterOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Values loaded asynchronously from the backend (all data, not just page)
|
|
||||||
if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) {
|
if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) {
|
||||||
return asyncFilterValues[columnKey];
|
return asyncFilterValues[columnKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: extract from current page data
|
if (!apiEndpoint && !hookData?.fetchFilterValues) {
|
||||||
const values = new Set<string>();
|
console.warn(
|
||||||
data.forEach(row => {
|
`FormGeneratorTable: Column "${columnKey}" is filterable ` +
|
||||||
const value = row[columnKey];
|
`but has no filterOptions, no hookData.fetchFilterValues, and no apiEndpoint. ` +
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
`Filter dropdown will be empty. Provide apiEndpoint (auto-fetches /filter-values) ` +
|
||||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
`or add filterOptions to the column config.`
|
||||||
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') {
|
return [];
|
||||||
values.add(value ? 'true' : 'false');
|
}, [detectedColumns, asyncFilterValues, apiEndpoint, hookData]);
|
||||||
} else {
|
|
||||||
values.add(String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(values).sort();
|
|
||||||
}, [data, detectedColumns, asyncFilterValues]);
|
|
||||||
|
|
||||||
// Close filter dropdown when clicking outside
|
// Close filter dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1131,7 +1248,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
topScrollbar.removeEventListener('scroll', syncTopToContainer);
|
topScrollbar.removeEventListener('scroll', syncTopToContainer);
|
||||||
tableContainer.removeEventListener('scroll', syncContainerToTop);
|
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)
|
// Track which cells are currently being updated (for loading state)
|
||||||
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -1828,54 +1945,104 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.filterDropdownOptions}>
|
<div className={styles.filterDropdownOptions}>
|
||||||
{/* "All" option to clear filter */}
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${styles.filterOption} ${!currentVal ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => clearFilter(column.key)}
|
||||||
|
>
|
||||||
|
({t('formgen.filter.all', 'Alle')})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${styles.filterOption} ${currentVal === 'true' ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => handleFilter(column.key, 'true')}
|
||||||
|
>
|
||||||
|
{t('formgen.filter.yes', 'Ja')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${styles.filterOption} ${currentVal === 'false' ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => handleFilter(column.key, 'false')}
|
||||||
|
>
|
||||||
|
{t('formgen.filter.no', 'Nein')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDate) {
|
||||||
|
const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {};
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||||
onClick={() => clearFilter(column.key)}
|
onClick={() => clearFilter(column.key)}
|
||||||
>
|
>
|
||||||
({t('formgen.filter.all', 'All')})
|
({t('formgen.filter.all', 'Alle')})
|
||||||
|
</div>
|
||||||
|
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||||
|
{t('formgen.filter.from', 'Von')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rangeVal.from || ''}
|
||||||
|
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||||
|
{t('formgen.filter.to', 'Bis')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rangeVal.to || ''}
|
||||||
|
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||||
|
onClick={() => clearFilter(column.key)}
|
||||||
|
>
|
||||||
|
({t('formgen.filter.all', 'Alle')})
|
||||||
</div>
|
</div>
|
||||||
{/* Filter values - loaded from backend or static filterOptions */}
|
|
||||||
{filterValuesLoading[column.key] ? (
|
{filterValuesLoading[column.key] ? (
|
||||||
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
|
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
{t('formgen.filter.loading', 'Lade Filterwerte...')}
|
{t('formgen.filter.loading', 'Lade Filterwerte...')}
|
||||||
</div>
|
</div>
|
||||||
) : (() => {
|
) : (
|
||||||
const allValues = getUniqueValuesForColumn(column.key);
|
<FilterValuesList
|
||||||
const isExpanded = expandedFilterColumns.has(column.key);
|
columnKey={column.key}
|
||||||
const displayLimit = isExpanded ? allValues.length : 100;
|
allValues={getUniqueValuesForColumn(column.key)}
|
||||||
const visibleValues = allValues.slice(0, displayLimit);
|
activeFilter={filters[column.key]}
|
||||||
const remaining = allValues.length - displayLimit;
|
onSelect={(value) => handleFilter(column.key, value)}
|
||||||
|
/>
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{visibleValues.map(value => (
|
|
||||||
<div
|
|
||||||
key={value}
|
|
||||||
className={`${styles.filterOption} ${filters[column.key] === value ? styles.filterOptionSelected : ''}`}
|
|
||||||
onClick={() => handleFilter(column.key, value)}
|
|
||||||
title={value}
|
|
||||||
>
|
|
||||||
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{remaining > 0 && (
|
|
||||||
<div
|
|
||||||
className={styles.filterOptionMore}
|
|
||||||
onClick={() => _toggleFilterExpand(column.key)}
|
|
||||||
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
|
||||||
>
|
|
||||||
+ {remaining} {t('formgen.filter.more', 'weitere anzeigen')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isExpanded && allValues.length > 100 && (
|
|
||||||
<div
|
|
||||||
className={styles.filterOptionMore}
|
|
||||||
onClick={() => _toggleFilterExpand(column.key)}
|
|
||||||
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
|
||||||
>
|
|
||||||
{t('formgen.filter.less', 'Weniger anzeigen')}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||||
|
FaFileContract,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -66,6 +67,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.user-access-overview': <FaUserShield />,
|
'page.admin.user-access-overview': <FaUserShield />,
|
||||||
'page.admin.userAccessOverview': <FaUserShield />,
|
'page.admin.userAccessOverview': <FaUserShield />,
|
||||||
'page.admin.billing': <FaMoneyBillAlt />,
|
'page.admin.billing': <FaMoneyBillAlt />,
|
||||||
|
'page.admin.subscriptions': <FaFileContract />,
|
||||||
'page.admin.automationEvents': <FaClock />,
|
'page.admin.automationEvents': <FaClock />,
|
||||||
'page.admin.automation-events': <FaClock />,
|
'page.admin.automation-events': <FaClock />,
|
||||||
'page.admin.logs': <FaFileAlt />,
|
'page.admin.logs': <FaFileAlt />,
|
||||||
|
|
|
||||||
84
src/hooks/useAdminSubscriptions.ts
Normal file
84
src/hooks/useAdminSubscriptions.ts
Normal file
|
|
@ -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<string, any>;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationState {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Ausstehend',
|
||||||
|
SCHEDULED: 'Geplant',
|
||||||
|
TRIALING: 'Testphase',
|
||||||
|
ACTIVE: 'Aktiv',
|
||||||
|
PAST_DUE: 'Überfällig',
|
||||||
|
EXPIRED: 'Abgelaufen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAdminSubscriptions() {
|
||||||
|
const [subscriptions, setSubscriptions] = useState<any[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<PaginationState | null>(null);
|
||||||
|
const { request, isLoading: loading, error } = useApiRequest();
|
||||||
|
|
||||||
|
const refetch = useCallback(async (params?: PaginationParams) => {
|
||||||
|
try {
|
||||||
|
const requestParams: Record<string, string> = {};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -472,22 +472,30 @@ export function useAutomationOperations() {
|
||||||
export function useAutomationTemplates() {
|
export function useAutomationTemplates() {
|
||||||
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
|
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
|
||||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { checkPermission } = usePermissions();
|
const { checkPermission } = usePermissions();
|
||||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||||
|
|
||||||
const fetchTemplates = useCallback(async () => {
|
const fetchTemplates = useCallback(async (params?: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchTemplatesApi(request);
|
const data = await fetchTemplatesApi(request, params);
|
||||||
setTemplates(data);
|
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) {
|
} catch (e: any) {
|
||||||
console.error('Error fetching templates:', e);
|
console.error('Error fetching templates:', e);
|
||||||
setError(e.message || 'Failed to fetch templates');
|
setError(e.message || 'Failed to fetch templates');
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
|
setPagination(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -555,11 +563,12 @@ export function useAutomationTemplates() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
data: templates, // Alias for FormGenerator compatibility
|
data: templates,
|
||||||
attributes,
|
attributes,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
permissions,
|
permissions,
|
||||||
|
pagination,
|
||||||
refetch,
|
refetch,
|
||||||
fetchTemplates,
|
fetchTemplates,
|
||||||
fetchAttributes,
|
fetchAttributes,
|
||||||
|
|
|
||||||
|
|
@ -41,18 +41,37 @@ const _formatNextRun = (nextRunTime: string | null): string => {
|
||||||
|
|
||||||
export const AdminAutomationEventsPage: React.FC = () => {
|
export const AdminAutomationEventsPage: React.FC = () => {
|
||||||
const [events, setEvents] = useState<AutomationEvent[]>([]);
|
const [events, setEvents] = useState<AutomationEvent[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||||
|
|
||||||
const _fetchEvents = useCallback(async () => {
|
const _fetchEvents = useCallback(async (params?: any) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await api.get('/api/admin/automation-events');
|
const requestParams: Record<string, string> = {};
|
||||||
// Map eventId to id for FormGeneratorTable compatibility
|
if (params && typeof params === 'object') {
|
||||||
setEvents(response.data.map((e: any) => ({ ...e, id: e.eventId })));
|
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) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Fehler beim Laden der Events');
|
setError(err.response?.data?.detail || 'Fehler beim Laden der Events');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -196,6 +215,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={events}
|
data={events}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
apiEndpoint="/api/admin/automation-events"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -212,6 +232,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
||||||
hookData={{
|
hookData={{
|
||||||
handleDelete: _handleDelete,
|
handleDelete: _handleDelete,
|
||||||
refetch: _fetchEvents,
|
refetch: _fetchEvents,
|
||||||
|
pagination,
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren."
|
emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,10 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
|
|
||||||
// Load roles when feature changes
|
// Load roles when feature changes
|
||||||
const fetchRoles = useCallback(async () => {
|
const fetchRoles = useCallback(async (params?: any) => {
|
||||||
if (!selectedFeatureCode) {
|
if (!selectedFeatureCode) {
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
return;
|
return;
|
||||||
|
|
@ -83,15 +85,32 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/features/templates/roles`, {
|
const requestParams: Record<string, string> = { featureCode: selectedFeatureCode };
|
||||||
params: { featureCode: selectedFeatureCode }
|
if (params && typeof params === 'object') {
|
||||||
});
|
const paginationObj: any = {};
|
||||||
const roleList = response.data || [];
|
if (params.page !== undefined) paginationObj.page = params.page;
|
||||||
setRoles(Array.isArray(roleList) ? roleList : []);
|
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) {
|
} catch (err: any) {
|
||||||
console.error('Error loading feature roles:', err);
|
console.error('Error loading feature roles:', err);
|
||||||
setError('Fehler beim Laden der Feature-Rollen');
|
setError('Fehler beim Laden der Feature-Rollen');
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
|
setPagination(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -383,6 +402,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
onDelete={handleDeleteRole}
|
onDelete={handleDeleteRole}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: fetchRoles,
|
refetch: fetchRoles,
|
||||||
|
pagination,
|
||||||
handleDelete: handleDeleteRole,
|
handleDelete: handleDeleteRole,
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Feature-Rollen gefunden"
|
emptyMessage="Keine Feature-Rollen gefunden"
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
]}
|
]}
|
||||||
hookData={{
|
hookData={{
|
||||||
handleDelete: handleDeleteInvitation,
|
handleDelete: handleDeleteInvitation,
|
||||||
refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
||||||
pagination,
|
pagination,
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Einladungen gefunden"
|
emptyMessage="Keine Einladungen gefunden"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export const FilesPage: React.FC = () => {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
|
pagination,
|
||||||
fetchFileById,
|
fetchFileById,
|
||||||
updateFileOptimistically,
|
updateFileOptimistically,
|
||||||
} = useUserFiles();
|
} = useUserFiles();
|
||||||
|
|
@ -479,6 +480,7 @@ export const FilesPage: React.FC = () => {
|
||||||
onDeleteMultiple={handleDeleteMultiple}
|
onDeleteMultiple={handleDeleteMultiple}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: _tableRefetch,
|
refetch: _tableRefetch,
|
||||||
|
pagination,
|
||||||
permissions,
|
permissions,
|
||||||
handleDelete: handleFileDelete,
|
handleDelete: handleFileDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Prompts</h1>
|
<h1 className={styles.pageTitle}>Prompts</h1>
|
||||||
|
|
@ -194,28 +194,6 @@ export const PromptsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!prompts || prompts.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Prompts...</span>
|
|
||||||
</div>
|
|
||||||
) : !prompts || prompts.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaFileAlt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Prompts vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie einen neuen Prompt, um loszulegen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
>
|
|
||||||
<FaPlus /> Ersten Prompt erstellen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={prompts}
|
data={prompts}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -255,7 +233,6 @@ export const PromptsPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Prompts gefunden"
|
emptyMessage="Keine Prompts gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,31 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
||||||
|
|
||||||
const _STATUS_LABELS: Record<string, string> = {
|
|
||||||
PENDING: 'Ausstehend',
|
|
||||||
SCHEDULED: 'Geplant',
|
|
||||||
TRIALING: 'Testphase',
|
|
||||||
ACTIVE: 'Aktiv',
|
|
||||||
PAST_DUE: 'Überfällig',
|
|
||||||
EXPIRED: 'Abgelaufen',
|
|
||||||
};
|
|
||||||
|
|
||||||
const _COLUMNS: ColumnConfig[] = [
|
const _COLUMNS: ColumnConfig[] = [
|
||||||
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, width: 180 },
|
{ key: 'mandateName', label: 'Mandant', type: 'text', sortable: true, filterable: true, width: 180 },
|
||||||
{ key: 'planTitle', label: 'Plan', type: 'text' as any, sortable: true, filterable: true, width: 180 },
|
{ key: 'planTitle', label: 'Plan', type: 'text', sortable: true, filterable: true, width: 180 },
|
||||||
{ key: 'status', label: 'Status', type: 'text' as any, sortable: true, filterable: true, width: 110 },
|
{ key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 110 },
|
||||||
{ key: 'recurring', label: 'Wiederkehrend', type: 'boolean' as any, sortable: true, filterable: true, width: 120 },
|
{ key: 'recurring', label: 'Wiederkehrend', type: 'boolean', sortable: true, filterable: true, width: 120 },
|
||||||
{ key: 'activeUsers', label: 'User', type: 'number' as any, sortable: true, width: 70 },
|
{ key: 'activeUsers', label: 'User', type: 'number', sortable: true, width: 70 },
|
||||||
{ key: 'activeInstances', label: 'Instanzen', type: 'number' as any, sortable: true, width: 90 },
|
{ key: 'activeInstances', label: 'Instanzen', type: 'number', sortable: true, width: 90 },
|
||||||
{ key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number' as any, sortable: true, width: 140 },
|
{ key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number', sortable: true, width: 140 },
|
||||||
{ key: 'startedAt', label: 'Gestartet', type: 'date' as any, sortable: true, width: 130 },
|
{ key: 'startedAt', label: 'Gestartet', type: 'date', sortable: true, filterable: true, width: 130 },
|
||||||
{ key: 'currentPeriodEnd', label: 'Periodenende', type: 'date' as any, sortable: true, width: 130 },
|
{ key: 'currentPeriodEnd', label: 'Periodenende', type: 'date', sortable: true, filterable: true, width: 130 },
|
||||||
{ key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number' as any, sortable: true, width: 100 },
|
{ key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number', sortable: true, width: 100 },
|
||||||
{ key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number' as any, sortable: true, width: 110 },
|
{ key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number', sortable: true, width: 110 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const AdminSubscriptionsPage: React.FC = () => {
|
const AdminSubscriptionsPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { request } = useApiRequest();
|
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
const [subscriptions, setSubscriptions] = useState<any[]>([]);
|
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
||||||
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 _handleForceCancel = useCallback(async (row: any) => {
|
const _handleForceCancel = useCallback(async (row: any) => {
|
||||||
const ok = await confirm(
|
const ok = await confirm(
|
||||||
|
|
@ -67,15 +36,15 @@ const AdminSubscriptionsPage: React.FC = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/api/subscription/force-cancel', { subscriptionId: row.id });
|
await api.post('/api/subscription/force-cancel', { subscriptionId: row.id });
|
||||||
await _loadSubscriptions();
|
await refetch();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Force cancel failed:', err);
|
console.error('Force cancel failed:', err);
|
||||||
}
|
}
|
||||||
}, [confirm, _loadSubscriptions]);
|
}, [confirm, refetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
||||||
<header className={styles.header}>
|
<header className={styles.pageHeader} style={{ flexShrink: 0 }}>
|
||||||
<h1>Subscription-Übersicht</h1>
|
<h1>Subscription-Übersicht</h1>
|
||||||
<p className={styles.subtitle}>Alle Abonnements aller Mandanten</p>
|
<p className={styles.subtitle}>Alle Abonnements aller Mandanten</p>
|
||||||
<button
|
<button
|
||||||
|
|
@ -88,26 +57,28 @@ const AdminSubscriptionsPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{loading ? (
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
<div className={styles.noData}>Lade Subscriptions…</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={subscriptions}
|
data={subscriptions}
|
||||||
columns={_COLUMNS}
|
columns={_COLUMNS}
|
||||||
apiEndpoint="/api/subscription/admin/all"
|
apiEndpoint="/api/subscription/admin/all"
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={50}
|
||||||
|
selectable={false}
|
||||||
|
hookData={{ refetch, pagination }}
|
||||||
customActions={[
|
customActions={[
|
||||||
{
|
{
|
||||||
id: 'forceCancel',
|
id: 'forceCancel',
|
||||||
label: 'Sofort kündigen',
|
title: 'Sofort kündigen',
|
||||||
icon: '✕',
|
icon: '✕',
|
||||||
variant: 'danger' as any,
|
onClick: (row: any) => _handleForceCancel(row),
|
||||||
onClick: (_id: string, row: any) => _handleForceCancel(row),
|
visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
|
||||||
isVisible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
emptyMessage="Keine Subscriptions vorhanden."
|
emptyMessage="Keine Subscriptions vorhanden."
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -485,20 +485,40 @@ interface MandateTransactionsTabProps {
|
||||||
const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandateId }) => {
|
const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandateId }) => {
|
||||||
const { request, isLoading: loading } = useApiRequest();
|
const { request, isLoading: loading } = useApiRequest();
|
||||||
const [transactions, setTransactions] = useState<any[]>([]);
|
const [transactions, setTransactions] = useState<any[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const _loadTransactions = useCallback(async () => {
|
const _loadTransactions = useCallback(async (params?: any) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const requestParams: Record<string, string> = {};
|
||||||
|
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({
|
const data = await request({
|
||||||
url: `/api/billing/admin/transactions/${mandateId}`,
|
url: `/api/billing/admin/transactions/${mandateId}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { limit: 500 },
|
params: requestParams,
|
||||||
});
|
});
|
||||||
|
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 : []);
|
setTransactions(Array.isArray(data) ? data : []);
|
||||||
|
setPagination(null);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden');
|
setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden');
|
||||||
setTransactions([]);
|
setTransactions([]);
|
||||||
|
setPagination(null);
|
||||||
}
|
}
|
||||||
}, [request, mandateId]);
|
}, [request, mandateId]);
|
||||||
|
|
||||||
|
|
@ -506,10 +526,6 @@ const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandate
|
||||||
_loadTransactions();
|
_loadTransactions();
|
||||||
}, [_loadTransactions]);
|
}, [_loadTransactions]);
|
||||||
|
|
||||||
const hookData = useMemo(() => ({
|
|
||||||
refetch: _loadTransactions,
|
|
||||||
}), [_loadTransactions]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '400px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '400px' }}>
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem 0' }}>
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem 0' }}>
|
||||||
|
|
@ -528,8 +544,8 @@ const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandate
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
emptyMessage="Keine Transaktionen für diesen Mandanten"
|
emptyMessage="Keine Transaktionen für diesen Mandanten"
|
||||||
onRefresh={_loadTransactions}
|
onRefresh={() => _loadTransactions()}
|
||||||
hookData={hookData}
|
hookData={{ refetch: _loadTransactions, pagination }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -489,10 +489,16 @@ export const BillingDataView: React.FC = () => {
|
||||||
setTransactionsError(null);
|
setTransactionsError(null);
|
||||||
|
|
||||||
const params: any = {};
|
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) {
|
if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
|
||||||
const { page, pageSize, sortBy, sortDirection, search, filters } = paginationParams;
|
const pObj: any = {};
|
||||||
params.pagination = JSON.stringify({ page, pageSize, sortBy, sortDirection, search, filters });
|
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 });
|
const response = await api.get('/api/billing/view/users/transactions', { params });
|
||||||
|
|
@ -526,10 +532,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
// hookData for FormGeneratorTable
|
// hookData for FormGeneratorTable
|
||||||
const transactionsHookData = useMemo(() => ({
|
const transactionsHookData = useMemo(() => ({
|
||||||
refetch: _loadTransactions,
|
refetch: _loadTransactions,
|
||||||
pagination: transactionsPagination ? {
|
pagination: transactionsPagination || undefined,
|
||||||
totalPages: transactionsPagination.totalPages,
|
|
||||||
totalItems: transactionsPagination.totalItems,
|
|
||||||
} : undefined,
|
|
||||||
}), [_loadTransactions, transactionsPagination]);
|
}), [_loadTransactions, transactionsPagination]);
|
||||||
|
|
||||||
// Table column definitions
|
// Table column definitions
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export const AutomationTemplatesView: React.FC = () => {
|
||||||
error,
|
error,
|
||||||
permissions,
|
permissions,
|
||||||
refetch,
|
refetch,
|
||||||
|
fetchTemplates,
|
||||||
|
pagination,
|
||||||
createTemplate,
|
createTemplate,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
deleteTemplate,
|
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 },
|
{ 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)}
|
onDelete={(template) => handleDelete(template.id)}
|
||||||
hookData={{ refetch, handleDelete, attributes }}
|
hookData={{ refetch: fetchTemplates, pagination, handleDelete, attributes }}
|
||||||
emptyMessage="Keine Vorlagen gefunden"
|
emptyMessage="Keine Vorlagen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
||||||
|
|
@ -163,25 +163,6 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!parcels || parcels.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Parzellen...</span>
|
|
||||||
</div>
|
|
||||||
) : !parcels || parcels.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaMapMarkerAlt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie eine neue Parzelle, um zu beginnen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neue Parzelle
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={parcels}
|
data={parcels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -223,7 +204,6 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Parzellen gefunden"
|
emptyMessage="Keine Parzellen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingParcel || isCreateMode) && (
|
{(editingParcel || isCreateMode) && (
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
||||||
|
|
@ -149,23 +149,6 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!projects || projects.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Projekte...</span>
|
|
||||||
</div>
|
|
||||||
) : !projects || projects.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaBuilding className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
|
||||||
+ Neues Projekt
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={projects}
|
data={projects}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -184,7 +167,6 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
||||||
emptyMessage="Keine Projekte gefunden"
|
emptyMessage="Keine Projekte gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingProject || isCreateMode) && (
|
{(editingProject || isCreateMode) && (
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Belege und Dokumente verwalten</p>
|
<p className={styles.pageSubtitle}>Belege und Dokumente verwalten</p>
|
||||||
|
|
@ -203,28 +203,6 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!documents || documents.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Dokumente...</span>
|
|
||||||
</div>
|
|
||||||
) : !documents || documents.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaFileAlt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Dokumente vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie ein neues Dokument, um zu beginnen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleCreateClick}
|
|
||||||
>
|
|
||||||
+ Neues Dokument
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={documents}
|
data={documents}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -268,7 +246,6 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Dokumente gefunden"
|
emptyMessage="Keine Dokumente gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Belege mit Buchungspositionen verknüpfen</p>
|
<p className={styles.pageSubtitle}>Belege mit Buchungspositionen verknüpfen</p>
|
||||||
|
|
@ -171,28 +171,6 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!links || links.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Verknüpfungen...</span>
|
|
||||||
</div>
|
|
||||||
) : !links || links.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaLink className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Verknüpfungen vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Verknüpfen Sie Belege mit Buchungspositionen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleCreateClick}
|
|
||||||
>
|
|
||||||
+ Neue Verknüpfung
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={links}
|
data={links}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -227,7 +205,6 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Verknüpfungen gefunden"
|
emptyMessage="Keine Verknüpfungen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.pageSubtitle}>Buchungspositionen verwalten</p>
|
<p className={styles.pageSubtitle}>Buchungspositionen verwalten</p>
|
||||||
|
|
@ -437,28 +437,6 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!positions || positions.length === 0) ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>Lade Positionen...</span>
|
|
||||||
</div>
|
|
||||||
) : !positions || positions.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<FaReceipt className={styles.emptyIcon} />
|
|
||||||
<h3 className={styles.emptyTitle}>Keine Positionen vorhanden</h3>
|
|
||||||
<p className={styles.emptyDescription}>
|
|
||||||
Erstellen Sie eine neue Position, um zu beginnen.
|
|
||||||
</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleCreateClick}
|
|
||||||
>
|
|
||||||
+ Neue Position
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={positions}
|
data={positions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -510,7 +488,6 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Positionen gefunden"
|
emptyMessage="Keine Positionen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue