diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index cc05e44..8094658 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -36,6 +36,29 @@ export interface BillingTransaction { userName?: string; } +/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */ +export interface BillingTransactionsPaginationParams { + page?: number; + pageSize?: number; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; + filters?: Record; + search?: string; + viewKey?: string; + groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; +} + +export interface BillingTransactionsPaginatedResponse { + items: BillingTransaction[]; + pagination?: { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + }; + groupLayout?: import('./connectionApi').GroupLayout; + appliedView?: { viewKey?: string; displayName?: string }; +} + export interface BillingSettings { id: string; mandateId: string; @@ -135,7 +158,31 @@ export async function fetchBalanceForMandate( } /** - * Fetch transaction history + * Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping). + * Endpoint: GET /api/billing/transactions?pagination=... + */ +export async function fetchTransactionsPaginated( + request: ApiRequestFunction, + params?: BillingTransactionsPaginationParams +): Promise { + const paginationObj: Record = {}; + if (params?.page !== undefined) paginationObj.page = params.page; + if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize; + if (params?.sort?.length) paginationObj.sort = params.sort; + if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters; + if (params?.search) paginationObj.search = params.search; + if (params?.viewKey) paginationObj.viewKey = params.viewKey; + if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; + + return await request({ + url: '/api/billing/transactions', + method: 'get', + params: { pagination: JSON.stringify(paginationObj) }, + }); +} + +/** + * Fetch transaction history (legacy array window) * Endpoint: GET /api/billing/transactions */ export async function fetchTransactions( diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 41a79e4..8c47c6d 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -55,19 +55,22 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - /** Scope request to items of this group (resolved server-side to itemIds IN-filter). */ - groupId?: string; - /** If set, persist this group tree on the backend before fetching (optimistic save). */ - saveGroupTree?: TableGroupNode[]; + /** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */ + viewKey?: string; + /** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */ + groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; } -export interface TableGroupNode { - id: string; - name: string; - itemIds: string[]; - subGroups: TableGroupNode[]; - order: number; - isExpanded: boolean; +export interface GroupBand { + path: string[]; + label: string; + startRowIndex: number; + rowCount: number; +} + +export interface GroupLayout { + levels: string[]; + bands: GroupBand[]; } export interface PaginatedResponse { @@ -78,8 +81,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; - /** Current group tree for this (user, contextKey) pair — undefined if no grouping configured. */ - groupTree?: TableGroupNode[]; + groupLayout?: GroupLayout; + appliedView?: { viewKey?: string; displayName?: string }; } export interface CreateConnectionData { @@ -138,8 +141,8 @@ export async function fetchConnections( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; + if (params.viewKey) paginationObj.viewKey = params.viewKey; + if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 7e2c67a..75151c3 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -34,8 +34,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; + groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; } export interface PaginatedResponse { @@ -46,6 +46,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; + groupLayout?: import('./connectionApi').GroupLayout; + appliedView?: { viewKey?: string; displayName?: string }; } // Type for the request function passed to API functions @@ -105,9 +107,9 @@ export async function fetchFiles( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; - + if (params.viewKey) paginationObj.viewKey = params.viewKey; + if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; + if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); } @@ -249,28 +251,13 @@ export async function deleteGroup( }); } -/** Collect all file IDs belonging to a group recursively (client-side, from known groupTree) */ +/** @deprecated Group tree removed — use view-based grouping (viewKey). Returns empty array. */ export function collectGroupItemIds( - groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>, - groupId: string + _groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>, + _groupId: string ): string[] { - const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => { - for (const node of nodes) { - if (node.id === groupId) { - const ids: string[] = [...node.itemIds]; - const sub = (n: { id: string; itemIds: string[]; subGroups: any[] }) => { - ids.push(...n.itemIds); - n.subGroups.forEach(sub); - }; - node.subGroups.forEach(sub); - return ids; - } - const found = collect(node.subGroups); - if (found) return found; - } - return null; - }; - return collect(groupTree) ?? []; + const collect = (): string[] | null => null; + return collect() ?? []; } // Note: The following operations require special handling (FormData, blob responses) diff --git a/src/api/mandateApi.ts b/src/api/mandateApi.ts index 7946395..24aee62 100644 --- a/src/api/mandateApi.ts +++ b/src/api/mandateApi.ts @@ -46,8 +46,7 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; } export interface PaginatedResponse { @@ -86,8 +85,7 @@ export async function fetchMandates( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; + if (params.viewKey) paginationObj.viewKey = params.viewKey; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/promptApi.ts b/src/api/promptApi.ts index e735ae0..164a633 100644 --- a/src/api/promptApi.ts +++ b/src/api/promptApi.ts @@ -49,8 +49,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; + groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; } export interface PaginatedResponse { @@ -61,6 +61,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; + groupLayout?: import('./connectionApi').GroupLayout; + appliedView?: { viewKey?: string; displayName?: string }; } export interface CreatePromptData { @@ -112,9 +114,9 @@ export async function fetchPrompts( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; - + if (params.viewKey) paginationObj.viewKey = params.viewKey; + if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; + if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); } diff --git a/src/api/tableViewApi.ts b/src/api/tableViewApi.ts new file mode 100644 index 0000000..08039e8 --- /dev/null +++ b/src/api/tableViewApi.ts @@ -0,0 +1,59 @@ +import api from '../api'; + +export interface TableListViewRow { + id: string; + userId?: string; + mandateId?: string | null; + contextKey: string; + viewKey: string; + displayName: string; + config: TableViewConfig; + updatedAt?: number; +} + +export interface TableViewConfig { + schemaVersion?: number; + filters?: Record; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; + groupByLevels?: Array<{ field: string; nullLabel?: string }>; + /** Section mode (`tableGroupLayoutMode="sections"`): stable keys (`sk`) of collapsed sections. */ + collapsedSectionKeys?: string[]; + /** Inline `groupLayout` bands: keys are `band.path.join('///')`. */ + collapsedGroupKeys?: string[]; +} + +export async function listTableViews(contextKey: string): Promise { + const { data } = await api.get('/api/table-views', { + params: { contextKey }, + }); + return Array.isArray(data) ? data : []; +} + +export async function getTableView(contextKey: string, viewKey: string): Promise { + const { data } = await api.get(`/api/table-views/${encodeURIComponent(viewKey)}`, { + params: { contextKey }, + }); + return data; +} + +export async function createTableView(payload: { + contextKey: string; + viewKey: string; + displayName: string; + config: TableViewConfig; +}): Promise { + const { data } = await api.post('/api/table-views', payload); + return data; +} + +export async function updateTableView( + viewId: string, + updates: { displayName?: string; viewKey?: string; config?: TableViewConfig }, +): Promise { + const { data } = await api.put(`/api/table-views/${encodeURIComponent(viewId)}`, updates); + return data; +} + +export async function deleteTableView(viewId: string): Promise { + await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`); +} diff --git a/src/api/userApi.ts b/src/api/userApi.ts index 98dd7a2..3615375 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -48,8 +48,7 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; - groupId?: string; - saveGroupTree?: any[]; + viewKey?: string; } export interface PaginatedResponse { @@ -154,8 +153,7 @@ export async function fetchUsers( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; - if (params.groupId) paginationObj.groupId = params.groupId; - if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; + if (params.viewKey) paginationObj.viewKey = params.viewKey; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 8b43b01..e57fce2 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -4,7 +4,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from './FormGeneratorControls.module.css'; import { Button } from '../../UiComponents/Button'; import { IoIosRefresh } from "react-icons/io"; -import { FaTrash, FaDownload, FaLayerGroup } from "react-icons/fa"; +import { FaTrash, FaDownload } from "react-icons/fa"; import type { AttributeType } from '../../../utils/attributeTypeMapper'; // Generic field/column config interface @@ -77,10 +77,6 @@ export interface FormGeneratorControlsProps { onSelectAllFiltered?: () => void; selectAllFilteredActive?: boolean; selectAllFilteredLoading?: boolean; - // Grouping - groupingEnabled?: boolean; - onCreateGroup?: () => void; - activeGroupId?: string | null; } export function FormGeneratorControls({ @@ -114,9 +110,6 @@ export function FormGeneratorControls({ onSelectAllFiltered, selectAllFilteredActive = false, selectAllFilteredLoading = false, - groupingEnabled = false, - onCreateGroup, - activeGroupId, }: FormGeneratorControlsProps) { const { t } = useLanguage(); @@ -186,9 +179,15 @@ export function FormGeneratorControls({ )} - {/* Search Controls with Pagination - Hide when items are selected */} - {searchable && selectedCount === 0 && ( + {/* Toolbar: optional search + filters badge + CSV + pagination (search is optional) */} + {selectedCount === 0 && + (searchable || + (pagination && supportsBackendPagination) || + !!onCsvExport || + !!onRefresh || + activeFiltersCount > 0) && (
+ {searchable && (
+ )} {activeFiltersCount > 0 && ( {activeFiltersCount} {t('Filter')} @@ -219,16 +219,6 @@ export function FormGeneratorControls({ {csvExporting ? t('Exportiere...') : 'CSV'} )} - {groupingEnabled && onCreateGroup && ( - - )} {onRefresh && ( + {!sectionCollapsed && ( + + key={`${sk}-r${refreshNonce}-${JSON.stringify(filters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`} + className={styles.groupSectionTableWrap} + columns={providedColumns} + data={[]} + searchable={false} + filterable={filterable} + sortable={sortable} + resizable={resizable} + pagination={pagination} + pageSize={pageSize} + pageSizeOptions={pageSizeOptions} + showPageSizeSelector={showPageSizeSelector} + selectable={selectable} + isRowSelectable={isRowSelectable} + inlineEditable={inlineEditable} + onInlineUpdate={onInlineUpdate} + inlineEditingRows={inlineEditingRows} + idField={idField} + actionButtons={actionButtons} + customActions={customActions} + onDelete={onDelete} + onDeleteMultiple={onDeleteMultiple} + batchActions={batchActions} + onRowClick={onRowClick} + onRowSelect={onRowSelect} + onSelectionChange={onSelectionChange} + getRowDataAttributes={getRowDataAttributes} + rowDraggable={rowDraggable} + onRowDragStart={onRowDragStart} + compact={compact} + localDataMode + viewKeyForQueries={activeViewKey} + initialSearchTerm={debouncedSearchTerm} + initialFilters={filters} + initialSort={sortConfigs} + apiEndpoint={apiEndpoint} + csvExportQueryParams={hookDataProp?.csvExportQueryParams} + csvExportContextFilters={sectionFilter} + csvExportFilenameSuffix={sk} + hookData={{ + ...hookDataProp, + refetch: async (p: any) => { + if (!hookDataProp?.refetchForSection) { + return { items: [], pagination: null }; + } + return hookDataProp.refetchForSection(p, sectionFilter, filters); + }, + ...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function' + ? { + fetchFilterValues: async (columnKey: string, crossFilters?: Record) => { + const merged: Record = { + ...filters, + ...(crossFilters || {}), + ...sectionFilter, + }; + return hookDataProp.fetchFilterValues(columnKey, merged); + }, + } + : {}), + }} + emptyMessage={emptyMessage} + /> + )} + + ); + })} +
+ + )} ); diff --git a/src/components/FormGenerator/GroupingManager/GroupRow.tsx b/src/components/FormGenerator/GroupingManager/GroupRow.tsx index 10f77ca..f48da2e 100644 --- a/src/components/FormGenerator/GroupingManager/GroupRow.tsx +++ b/src/components/FormGenerator/GroupingManager/GroupRow.tsx @@ -4,7 +4,12 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; import { useConfirm } from '../../../hooks/useConfirm'; import styles from './GroupRow.module.css'; import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css'; -import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable'; + +/** Legacy folder-tree row model (client-side group tree); kept for GroupFolderRow typings. */ +export interface TableGroupNode { + name: string; + itemIds: string[]; +} import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa'; // --------------------------------------------------------------------------- diff --git a/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css b/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css new file mode 100644 index 0000000..090715d --- /dev/null +++ b/src/components/FormGenerator/TableViewsBar/TableViewsBar.module.css @@ -0,0 +1,286 @@ +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + padding: 8px 0 12px; + border-bottom: 1px solid var(--color-border, #e2e8f0); + margin-bottom: 8px; +} + +.popoverAnchor { + position: relative; +} + +.groupTrigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--color-border, #cbd5e1); + background: var(--color-bg, #fff); + color: var(--color-text, #0f172a); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; +} + +.groupIcon { + display: block; + font-size: 16px; + opacity: 0.9; +} + +.groupTrigger:hover { + background: var(--bg-hover, rgba(15, 23, 42, 0.04)); + border-color: var(--color-primary, #64748b); +} + +.groupTriggerOpen { + border-color: var(--color-primary, #4a6fa5); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 25%, transparent); +} + +.popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 4200; + min-width: min(360px, calc(100vw - 24px)); + padding: 14px 14px 12px; + border-radius: 12px; + border: 1px solid var(--color-border, #e2e8f0); + background: var(--color-bg, #ffffff); + color: var(--color-text, #0f172a); + box-shadow: 0 14px 40px rgba(15, 23, 42, 0.12); +} + +.popoverTitle { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary, #94a3b8); + margin: 0 0 6px; +} + +.popoverHint { + margin: 0 0 12px; + font-size: 12px; + line-height: 1.45; + color: var(--text-muted, #64748b); +} + +.levelList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.levelRow { + display: grid; + grid-template-columns: 1fr 118px 36px; + gap: 8px; + align-items: center; +} + +.select, +.selectOrder { + padding: 8px 10px; + font-size: 13px; + border-radius: 8px; + border: 1px solid var(--color-border, #cbd5e1); + background: var(--color-bg, #fff); + color: var(--color-text, #0f172a); + box-sizing: border-box; + width: 100%; + min-width: 0; +} + +.select:disabled, +.selectOrder:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary, #94a3b8); + cursor: pointer; +} + +.iconBtn:hover:not(:disabled) { + color: #fecaca; + background: rgba(239, 68, 68, 0.12); +} + +.iconBtn:disabled { + opacity: 0.25; + cursor: not-allowed; +} + +.addLevelBtn { + margin-top: 12px; + width: 100%; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; + border-radius: 8px; + border: 1px dashed var(--color-border, #475569); + background: transparent; + color: var(--text-secondary, #94a3b8); + cursor: pointer; +} + +.addLevelBtn:hover { + border-color: var(--color-primary, #4a6fa5); + color: var(--color-primary, #7dd3fc); +} + +.activeSummary { + font-size: 12px; + color: var(--text-secondary, #64748b); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewBlock { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.viewLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary, #64748b); +} + +.viewSelect { + min-width: 160px; + padding: 6px 10px; + font-size: 13px; + border-radius: 8px; + border: 1px solid var(--color-border, #cbd5e1); + background: var(--color-bg, #fff); + color: var(--color-text, #0f172a); +} + +.btnGhost { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 8px; + border: 1px solid var(--color-border, #cbd5e1); + background: transparent; + color: var(--color-text, #334155); + cursor: pointer; +} + +.btnGhost:hover { + background: var(--bg-hover, #f1f5f9); +} + +.btnDangerGhost { + padding: 6px 12px; + font-size: 12px; + border-radius: 8px; + border: 1px solid #fecaca; + background: transparent; + color: #b91c1c; + cursor: pointer; +} + +.btnDangerGhost:hover { + background: #fef2f2; +} + +.btnPrimary { + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + border-radius: 8px; + border: none; + background: var(--color-primary, #4a6fa5); + color: #fff; + cursor: pointer; +} + +.btnPrimary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.modalBackdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.5); + z-index: 4500; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal { + background: var(--color-bg, #fff); + color: var(--color-text, #0f172a); + border-radius: 12px; + padding: 20px 22px; + max-width: 420px; + width: 100%; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2); +} + +.modal h3 { + margin: 0 0 8px; + font-size: 17px; +} + +.modalHint { + margin: 0 0 14px; + font-size: 13px; + color: var(--text-secondary, #64748b); + line-height: 1.45; +} + +.modalField { + margin-bottom: 12px; +} + +.modalField label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + color: var(--text-secondary, #64748b); +} + +.modalField input { + width: 100%; + padding: 8px 10px; + font-size: 14px; + border: 1px solid var(--color-border, #cbd5e1); + border-radius: 8px; + box-sizing: border-box; +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; +} diff --git a/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx b/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx new file mode 100644 index 0000000..e59743c --- /dev/null +++ b/src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx @@ -0,0 +1,337 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { FaLayerGroup, FaTrash } from 'react-icons/fa'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import styles from './TableViewsBar.module.css'; + +export interface TableViewOption { + id: string; + viewKey: string; + displayName: string; +} + +/** One grouping level (ClickUp-style): column + band order for that level. */ +export interface GroupByLevelSpec { + field: string; + direction: 'asc' | 'desc'; +} + +export interface TableViewsBarProps { + views: TableViewOption[]; + loadingViews: boolean; + activeViewKey: string | null; + activeViewId: string | null; + groupByLevels: GroupByLevelSpec[]; + onGroupByLevelsChange: (levels: GroupByLevelSpec[]) => void; + onSelectView: (viewKey: string | null) => void; + columnOptions: Array<{ key: string; label: string }>; + onCreateView: (displayName: string, viewKey: string) => void | Promise; + /** When a saved view is active, overwrite its config (filters, sort, grouping, folds). Optional. */ + onSaveActiveView?: () => void | Promise; + onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise; + onDeleteView?: (viewId: string) => void | Promise; + onReloadViews: () => void; +} + +function slugify(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-_]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'view'; +} + +export function groupLevelsToApiPayload(levels: GroupByLevelSpec[]) { + return levels + .filter((l) => l.field) + .map((l) => ({ field: l.field, nullLabel: '—', direction: l.direction })); +} + +function commitLevels( + next: GroupByLevelSpec[], + activeViewId: string | null, + onGroupByLevelsChange: (l: GroupByLevelSpec[]) => void, + onUpdateViewGrouping: (id: string, l: GroupByLevelSpec[]) => void | Promise, +) { + onGroupByLevelsChange(next); + if (activeViewId) { + void Promise.resolve(onUpdateViewGrouping(activeViewId, next)); + } +} + +export function TableViewsBar({ + views, + loadingViews, + activeViewKey, + activeViewId, + groupByLevels, + onGroupByLevelsChange, + onSelectView, + columnOptions, + onCreateView, + onSaveActiveView, + onUpdateViewGrouping, + onDeleteView, + onReloadViews, +}: TableViewsBarProps) { + const { t } = useLanguage(); + const [groupMenuOpen, setGroupMenuOpen] = useState(false); + const wrapRef = useRef(null); + const [saveOpen, setSaveOpen] = useState(false); + const [newName, setNewName] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!groupMenuOpen) return; + const onDoc = (e: MouseEvent) => { + const el = wrapRef.current; + if (el && !el.contains(e.target as Node)) setGroupMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setGroupMenuOpen(false); + }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDoc); + document.removeEventListener('keydown', onKey); + }; + }, [groupMenuOpen]); + + const levelsForUi = useMemo( + () => (groupByLevels.length > 0 ? groupByLevels : [{ field: '', direction: 'asc' as const }]), + [groupByLevels], + ); + + const usedFields = useMemo( + () => new Set(groupByLevels.map((l) => l.field).filter(Boolean)), + [groupByLevels], + ); + + const columnsForRow = useCallback( + (_rowIdx: number, currentField: string) => + columnOptions.filter((c) => c.key === currentField || !usedFields.has(c.key) || !c.key), + [columnOptions, usedFields], + ); + + const [overwriteSaving, setOverwriteSaving] = useState(false); + + const _onClickSave = useCallback(async () => { + if (activeViewId && onSaveActiveView) { + setOverwriteSaving(true); + try { + await onSaveActiveView(); + await onReloadViews(); + } catch (e) { + console.error('Save active view failed', e); + } finally { + setOverwriteSaving(false); + } + return; + } + setSaveOpen(true); + setNewName(''); + }, [activeViewId, onSaveActiveView, onReloadViews]); + + const _saveNew = async () => { + const name = newName.trim(); + const slug = slugify(name); + if (!name || !slug) return; + setSaving(true); + try { + await onCreateView(name, slug); + setSaveOpen(false); + setNewName(''); + await onReloadViews(); + } finally { + setSaving(false); + } + }; + + const updateLevel = (idx: number, patch: Partial) => { + const working = levelsForUi.map((l, i) => (i === idx ? { ...l, ...patch } : l)); + const normalized = working.filter((l) => l.field); + commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping); + }; + + const addLevelRow = () => { + commitLevels( + [...groupByLevels, { field: '', direction: 'asc' }], + activeViewId, + onGroupByLevelsChange, + onUpdateViewGrouping, + ); + }; + + const removeLevel = (idx: number) => { + const working = levelsForUi.filter((_, i) => i !== idx); + const normalized = working.filter((l) => l.field); + commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping); + }; + + const summary = + groupByLevels.length === 0 + ? t('Keine') + : groupByLevels + .filter((l) => l.field) + .map((l) => columnOptions.find((c) => c.key === l.field)?.label ?? l.field) + .join(' › '); + + return ( +
+
+ + {groupMenuOpen && ( +
+
{t('Gruppieren nach')}
+

{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}

+
+ {levelsForUi.map((level, idx) => ( +
+ + + +
+ ))} +
+ +
+ )} +
+ + + {groupByLevels.filter((l) => l.field).length === 0 + ? t('Nicht gruppiert') + : `${t('Aktiv')}: ${summary}`} + + +
+ {t('Ansicht')} + + + {activeViewId && onDeleteView && ( + + )} +
+ + {saveOpen && ( +
{ + if (e.target === e.currentTarget) setSaveOpen(false); + }} + > +
e.stopPropagation()}> +

{t('Neue Ansicht')}

+

{t('Übernimmt Filter, Sortierung und Gruppierung.')}

+
+ + setNewName(e.target.value)} + placeholder={t('z. B. Nach Status')} + autoFocus + /> +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/components/FormGenerator/TableViewsBar/index.ts b/src/components/FormGenerator/TableViewsBar/index.ts new file mode 100644 index 0000000..da9924b --- /dev/null +++ b/src/components/FormGenerator/TableViewsBar/index.ts @@ -0,0 +1 @@ +export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar'; diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts index de8eb7d..8be65d9 100644 --- a/src/hooks/useBilling.ts +++ b/src/hooks/useBilling.ts @@ -11,6 +11,7 @@ import { fetchBalances, fetchBalanceForMandate, fetchTransactions, + fetchTransactionsPaginated, fetchStatistics, fetchAllowedProviders, fetchSettingsAdmin, @@ -31,7 +32,9 @@ import { type MandateUserSummary, type StatisticsRangeRequest, type BillingBucketSize, + type BillingTransactionsPaginationParams, } from '../api/billingApi'; +import type { GroupLayout } from '../api/connectionApi'; // Re-export types export type { @@ -47,7 +50,7 @@ export type { BillingBucketSize, }; -export type { TransactionType, ReferenceType } from '../api/billingApi'; +export type { TransactionType, ReferenceType, BillingTransactionsPaginationParams } from '../api/billingApi'; /** * Hook for user billing operations @@ -55,6 +58,17 @@ export type { TransactionType, ReferenceType } from '../api/billingApi'; export function useBilling() { const [balances, setBalances] = useState([]); const [transactions, setTransactions] = useState([]); + const [transactionsPagination, setTransactionsPagination] = useState<{ + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; + } | null>(null); + const [transactionsGroupLayout, setTransactionsGroupLayout] = useState(null); + const [transactionsAppliedView, setTransactionsAppliedView] = useState<{ + viewKey?: string; + displayName?: string; + } | null>(null); const [statistics, setStatistics] = useState(null); const [allowedProviders, setAllowedProviders] = useState([]); const { request, isLoading: loading, error } = useApiRequest(); @@ -87,14 +101,38 @@ export function useBilling() { try { const data = await fetchTransactions(request, limit, offset); setTransactions(Array.isArray(data) ? data : []); + setTransactionsPagination(null); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); return data; } catch (err) { console.error('Error loading transactions:', err); setTransactions([]); + setTransactionsPagination(null); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); return []; } }, [request]); + const refetchTransactions = useCallback(async (params?: BillingTransactionsPaginationParams) => { + try { + const data = await fetchTransactionsPaginated(request, params); + setTransactions(Array.isArray(data.items) ? data.items : []); + setTransactionsPagination(data.pagination ?? null); + setTransactionsGroupLayout(data.groupLayout ?? null); + setTransactionsAppliedView(data.appliedView ?? null); + return data; + } catch (err) { + console.error('Error loading transactions:', err); + setTransactions([]); + setTransactionsPagination(null); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); + return null; + } + }, [request]); + const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => { try { const data = await fetchStatistics(request, range); @@ -129,6 +167,9 @@ export function useBilling() { return { balances, transactions, + transactionsPagination, + transactionsGroupLayout, + transactionsAppliedView, statistics, allowedProviders, loading, @@ -136,6 +177,7 @@ export function useBilling() { loadBalances, loadBalanceForMandate, loadTransactions, + refetchTransactions, loadStatistics, loadAllowedProviders, refetch: loadBalances, diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index b3b43d7..299480c 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -17,12 +17,13 @@ import { type AttributeDefinition, type PaginationParams, type CreateConnectionData, - type ConnectResponse + type ConnectResponse, + type PaginatedResponse, + type GroupLayout, } from '../api/connectionApi'; // Re-export types for backward compatibility export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse }; -export type { TableGroupNode } from '../api/connectionApi'; // Hook for managing connections export function useConnections() { @@ -35,7 +36,8 @@ export function useConnections() { totalItems: number; totalPages: number; } | null>(null); - const [groupTree, setGroupTree] = useState([]); + const [groupLayout, setGroupLayout] = useState(null); + const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null); const [isConnecting, setIsConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const { request, isLoading, error } = useApiRequest(); @@ -91,6 +93,69 @@ export function useConnections() { } }, [checkPermission]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + page: 1, + pageSize: 25, + groupByLevels: [ + { + field: base.groupField, + nullLabel: '—', + direction: base.groupDirection || 'asc', + }, + ], + }; + if (base.search) (pObj as { search?: string }).search = base.search; + if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; + if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; + if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; + const { data } = await api.get('/api/connections/', { + params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) }, + }); + return Array.isArray(data?.groups) ? data.groups : []; + }, + [], + ); + + const refetchForSection = useCallback( + async ( + paginationParams: any, + sectionFilter: Record, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + page: paginationParams.page, + pageSize: paginationParams.pageSize, + filters: mergedFilters, + groupByLevels: [], + }; + if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort; + if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search; + if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; + const { data } = await api.get('/api/connections/', { + params: { pagination: JSON.stringify(pObj) }, + }); + if (data && typeof data === 'object' && 'items' in data) { + return { items: data.items, pagination: data.pagination }; + } + return { items: [], pagination: null }; + }, + [], + ); + // Fetch connections with pagination support const fetchConnections = useCallback(async (params?: PaginationParams): Promise => { try { @@ -103,14 +168,15 @@ export function useConnections() { if (data.pagination) { setPagination(data.pagination); } - if (Array.isArray(data.groupTree)) { - setGroupTree(data.groupTree); - } + setGroupLayout((data as PaginatedResponse).groupLayout ?? null); + setAppliedView((data as PaginatedResponse).appliedView ?? null); } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; setConnections(items); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } return Array.isArray(data) ? data : (data?.items || []); @@ -118,6 +184,8 @@ export function useConnections() { console.error('Error fetching connections:', error); setConnections([]); setPagination(null); + setGroupLayout(null); + setAppliedView(null); throw error; } }, [request]); @@ -824,6 +892,8 @@ export function useConnections() { attributes, permissions, pagination, + groupLayout, + appliedView, generateEditFieldsFromAttributes, ensureAttributesLoaded, fetchAttributes, @@ -832,7 +902,8 @@ export function useConnections() { updateOptimistically, handleInlineUpdate, fetchConnectionById, - groupTree, + fetchGroupSectionSummaries, + refetchForSection, }; } diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 72d390d..a770388 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -22,7 +22,6 @@ import { moveFiles as moveFilesApi, type FolderInfo, } from '../api/fileApi'; -import type { TableGroupNode } from '../api/connectionApi'; export interface FilePreviewResult { success: boolean; @@ -69,6 +68,7 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + viewKey?: string; } // Files list hook @@ -82,7 +82,8 @@ export function useUserFiles() { totalItems: number; totalPages: number; } | null>(null); - const [groupTree, setGroupTree] = useState([]); + const [groupLayout, setGroupLayout] = useState(null); + const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -140,6 +141,69 @@ export function useUserFiles() { } }, [checkPermission]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + page: 1, + pageSize: 25, + groupByLevels: [ + { + field: base.groupField, + nullLabel: '—', + direction: base.groupDirection || 'asc', + }, + ], + }; + if (base.search) (pObj as { search?: string }).search = base.search; + if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; + if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; + if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; + const { data } = await api.get('/api/files/list', { + params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) }, + }); + return Array.isArray(data?.groups) ? data.groups : []; + }, + [], + ); + + const refetchForSection = useCallback( + async ( + paginationParams: PaginationParams & { page: number; pageSize: number }, + sectionFilter: Record, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + page: paginationParams.page, + pageSize: paginationParams.pageSize, + filters: mergedFilters, + groupByLevels: [], + }; + if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort; + if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search; + if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; + const { data } = await api.get('/api/files/list', { + params: { pagination: JSON.stringify(pObj) }, + }); + if (data && typeof data === 'object' && 'items' in data) { + return { items: data.items, pagination: data.pagination }; + } + return { items: [], pagination: null }; + }, + [], + ); + const fetchFiles = useCallback(async (params?: PaginationParams) => { // Check if user is authenticated before fetching files const cachedUser = getUserDataCache(); @@ -182,28 +246,20 @@ export function useUserFiles() { if (data.pagination) { setPagination(data.pagination); } - if (Array.isArray((data as any).groupTree)) { - setGroupTree((data as any).groupTree); - } + setGroupLayout((data as any).groupLayout ?? null); + setAppliedView((data as any).appliedView ?? null); } else { - // Handle non-paginated response (backward compatibility) - console.log('📋 Processing non-paginated response:', { - isArray: Array.isArray(data), - dataLength: Array.isArray(data) ? data.length : 'not an array', - firstItemRaw: Array.isArray(data) && data.length > 0 ? data[0] : null, - allDataRaw: data - }); - - // Use backend data directly - no mapping needed, just like prompts const items = Array.isArray(data) ? data : []; - console.log('📊 Final files array (non-paginated, using backend data directly):', items); setFiles(items); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } } catch (error: any) { - // Error is already handled by useApiRequest setFiles([]); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } }, [request]); @@ -338,10 +394,13 @@ export function useUserFiles() { attributes, permissions, pagination, - groupTree, + groupLayout, + appliedView, fetchFileById, generateEditFieldsFromAttributes, - ensureAttributesLoaded + ensureAttributesLoaded, + fetchGroupSectionSummaries, + refetchForSection, }; } diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index 20870e7..26cef0b 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -13,7 +13,6 @@ import { type AttributeDefinition, type PaginationParams } from '../api/promptApi'; -import type { TableGroupNode } from '../api/connectionApi'; // Re-export types for backward compatibility export type { Prompt, AttributeDefinition, PaginationParams }; @@ -35,7 +34,8 @@ export function usePrompts() { totalItems: number; totalPages: number; } | null>(null); - const [groupTree, setGroupTree] = useState([]); + const [groupLayout, setGroupLayout] = useState(null); + const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); @@ -90,6 +90,69 @@ export function usePrompts() { } }, [checkPermission]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + page: 1, + pageSize: 25, + groupByLevels: [ + { + field: base.groupField, + nullLabel: '—', + direction: base.groupDirection || 'asc', + }, + ], + }; + if (base.search) (pObj as { search?: string }).search = base.search; + if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; + if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; + if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; + const { data } = await api.get('/api/prompts', { + params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) }, + }); + return Array.isArray(data?.groups) ? data.groups : []; + }, + [], + ); + + const refetchForSection = useCallback( + async ( + paginationParams: any, + sectionFilter: Record, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + page: paginationParams.page, + pageSize: paginationParams.pageSize, + filters: mergedFilters, + groupByLevels: [], + }; + if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort; + if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search; + if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; + const { data } = await api.get('/api/prompts', { + params: { pagination: JSON.stringify(pObj) }, + }); + if (data && typeof data === 'object' && 'items' in data) { + return { items: data.items, pagination: data.pagination }; + } + return { items: [], pagination: null }; + }, + [], + ); + const fetchPrompts = useCallback(async (params?: PaginationParams) => { try { const data = await fetchPromptsApi(request, params); @@ -101,19 +164,22 @@ export function usePrompts() { if (data.pagination) { setPagination(data.pagination); } - if (Array.isArray((data as any).groupTree)) { - setGroupTree((data as any).groupTree); - } + setGroupLayout(data.groupLayout ?? null); + setAppliedView(data.appliedView ?? null); } else { // Handle non-paginated response (backward compatibility) const items = Array.isArray(data) ? data : []; setPrompts(items); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } } catch (error: any) { // Error is already handled by useApiRequest setPrompts([]); setPagination(null); + setGroupLayout(null); + setAppliedView(null); } }, [request]); @@ -459,11 +525,14 @@ export function usePrompts() { attributes, permissions, pagination, - groupTree, + groupLayout, + appliedView, fetchPromptById, generateEditFieldsFromAttributes, generateCreateFieldsFromAttributes, - ensureAttributesLoaded + ensureAttributesLoaded, + fetchGroupSectionSummaries, + refetchForSection, }; } diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 39673ca..64e7826 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -30,11 +30,15 @@ export const ConnectionsPage: React.FC = () => { attributes, permissions, pagination, + groupLayout, + appliedView, loading, error, refetch, fetchConnectionById, updateOptimistically, + fetchGroupSectionSummaries, + refetchForSection, deleteConnection, handleInlineUpdate, createConnectionAndAuth, @@ -44,7 +48,6 @@ export const ConnectionsPage: React.FC = () => { refreshMicrosoftToken, refreshGoogleToken, isConnecting, - groupTree, } = useConnections(); const [editingConnection, setEditingConnection] = useState(null); @@ -415,6 +418,8 @@ export const ConnectionsPage: React.FC = () => { data={connections} columns={columns} apiEndpoint="/api/connections/" + tableContextKey="connections" + tableGroupLayoutMode="sections" loading={loading} pagination={true} pageSize={25} @@ -467,12 +472,14 @@ export const ConnectionsPage: React.FC = () => { refetch, permissions, pagination, + groupLayout, + appliedView, handleDelete: deleteConnection, handleInlineUpdate, updateOptimistically, - groupTree, + fetchGroupSectionSummaries, + refetchForSection, }} - groupingConfig={{ contextKey: 'connections', enabled: true }} emptyMessage={t('Keine Verbindungen gefunden')} /> diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index d1d019e..97b6b85 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -8,7 +8,7 @@ */ import React, { useState, useMemo, useEffect, useRef, useCallback, type PointerEvent as RPointerEvent } from 'react'; -import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; +import { useUserFiles, useFileOperations, type PaginationParams } from '../../hooks/useFiles'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree'; @@ -50,8 +50,12 @@ export const FilesPage: React.FC = () => { error, refetch: tableRefetch, pagination, + groupLayout, + appliedView, fetchFileById, updateFileOptimistically, + fetchGroupSectionSummaries: fetchGroupSectionSummariesFromHook, + refetchForSection: refetchForSectionFromHook, } = useUserFiles(); const { @@ -108,6 +112,39 @@ export const FilesPage: React.FC = () => { await tableRefetch(nextParams); }, [tableRefetch, selectedFolderId, viewMode]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const filters = { ...(base.filters || {}) }; + if (viewMode === 'folder' && selectedFolderId) { + filters.folderId = selectedFolderId; + } + return fetchGroupSectionSummariesFromHook({ ...base, filters }); + }, + [fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId], + ); + + const refetchForSection = useCallback( + async ( + paginationParams: PaginationParams & { page: number; pageSize: number }, + sectionFilter: Record, + parentColumnFilters?: Record, + ) => { + const merged = { ...(parentColumnFilters || {}) }; + if (viewMode === 'folder' && selectedFolderId) { + merged.folderId = selectedFolderId; + } + return refetchForSectionFromHook(paginationParams, sectionFilter, merged); + }, + [refetchForSectionFromHook, viewMode, selectedFolderId], + ); + const _refreshAll = useCallback(async () => { await _tableRefetch({ page: 1, pageSize: 25 }); setTreeKey(k => k + 1); @@ -409,6 +446,8 @@ export const FilesPage: React.FC = () => { data={tableFiles || []} columns={columns} apiEndpoint="/api/files/list" + tableContextKey="files/list" + tableGroupLayoutMode="sections" loading={tableLoading} pagination={true} pageSize={25} @@ -459,11 +498,15 @@ export const FilesPage: React.FC = () => { hookData={{ refetch: _tableRefetch, pagination, + groupLayout, + appliedView, permissions, handleDelete: handleFileDelete, handleInlineUpdate, updateOptimistically: updateFileOptimistically, previewingFiles, + fetchGroupSectionSummaries, + refetchForSection, }} emptyMessage={t('Keine Dateien gefunden')} /> diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 86eba34..3cbc1fa 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -31,12 +31,15 @@ export const PromptsPage: React.FC = () => { attributes, permissions, pagination, + groupLayout, + appliedView, loading, error, refetch, - groupTree, fetchPromptById, updateOptimistically, + fetchGroupSectionSummaries, + refetchForSection, } = usePrompts(); // Operations hook @@ -205,6 +208,8 @@ export const PromptsPage: React.FC = () => { data={prompts} columns={columns} apiEndpoint="/api/prompts" + tableContextKey="prompts" + tableGroupLayoutMode="sections" loading={loading} pagination={true} pageSize={25} @@ -234,12 +239,14 @@ export const PromptsPage: React.FC = () => { refetch: _tableRefetch, permissions, pagination, + groupLayout, + appliedView, handleDelete: handlePromptDelete, handleInlineUpdate, updateOptimistically, - groupTree, + fetchGroupSectionSummaries, + refetchForSection, }} - groupingConfig={{ contextKey: 'prompts', enabled: true }} emptyMessage={t('Keine Prompts gefunden')} /> diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index 23bb649..de49b0a 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -6,13 +6,36 @@ .billingDashboard { padding: 1.5rem; - height: 100%; + /* Fill MainLayout outletShell (flex column); height:100% alone does not grow the flex item */ + flex: 1; + min-height: 0; width: 100%; display: flex; flex-direction: column; + overflow-x: hidden; overflow-y: auto; } +/* Flex host for tab panels so the transactions tab can grow to the viewport */ +.billingTabBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + /* Overview/Diagramme: scroll; Transaktionen: single flex child fills height */ + overflow-x: hidden; + overflow-y: auto; +} + +/* Transactions tab: consume remaining viewport so nested tables can flex */ +.transactionsTabLayout { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + .pageHeader { margin-bottom: 2rem; } diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 0dd3caf..ade7771 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -19,6 +19,7 @@ import type { AttributeDefinition } from '../../api/attributesApi'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { useBilling, type BillingBucketSize } from '../../hooks/useBilling'; import { UserTransaction } from '../../api/billingApi'; +import type { GroupLayout } from '../../api/connectionApi'; import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import { useLanguage } from '../../providers/language/LanguageContext'; import { @@ -343,6 +344,11 @@ export const BillingDataView: React.FC = () => { const [transactionsLoading, setTransactionsLoading] = useState(false); const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(null); + const [transactionsGroupLayout, setTransactionsGroupLayout] = useState(null); + const [transactionsAppliedView, setTransactionsAppliedView] = useState<{ + viewKey?: string; + displayName?: string; + } | null>(null); useEffect(() => { fetchAttributes(request, 'BillingTransactionView') @@ -479,6 +485,8 @@ export const BillingDataView: React.FC = () => { if (paginationParams.sort) pObj.sort = paginationParams.sort; if (paginationParams.filters) pObj.filters = paginationParams.filters; if (paginationParams.search) pObj.search = paginationParams.search; + if (paginationParams.viewKey) pObj.viewKey = paginationParams.viewKey; + if (paginationParams.groupByLevels !== undefined) pObj.groupByLevels = paginationParams.groupByLevels; if (Object.keys(pObj).length > 0) { params.pagination = JSON.stringify(pObj); } @@ -489,20 +497,96 @@ export const BillingDataView: React.FC = () => { if (data && typeof data === 'object' && 'items' in data) { setTransactions(Array.isArray(data.items) ? data.items : []); - if (data.pagination) { - setTransactionsPagination(data.pagination); - } + setTransactionsPagination(data.pagination ?? null); + setTransactionsGroupLayout(data.groupLayout ?? null); + setTransactionsAppliedView(data.appliedView ?? null); + return data; } else { setTransactions(Array.isArray(data) ? data : []); + setTransactionsPagination(null); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); + return data; } } catch (err: any) { console.error('Failed to load transactions:', err); setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen')); + setTransactionsGroupLayout(null); + setTransactionsAppliedView(null); } finally { setTransactionsLoading(false); } + return null; }, [_scopeParams, t]); + const fetchGroupSectionSummaries = useCallback( + async (base: { + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: string }>; + viewKey?: string | null; + groupField: string; + groupDirection?: 'asc' | 'desc'; + }) => { + const pObj: Record = { + page: 1, + pageSize: 25, + groupByLevels: [ + { + field: base.groupField, + nullLabel: '—', + direction: base.groupDirection || 'asc', + }, + ], + }; + if (base.search) (pObj as { search?: string }).search = base.search; + if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; + if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; + if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; + const params: Record = { + ..._scopeParams, + mode: 'groupSummary', + pagination: JSON.stringify(pObj), + }; + const { data } = await api.get('/api/billing/view/users/transactions', { params }); + return Array.isArray(data?.groups) ? data.groups : []; + }, + [_scopeParams], + ); + + const refetchForSection = useCallback( + async ( + paginationParams: any, + sectionFilter: Record, + parentColumnFilters?: Record, + ) => { + const mergedFilters = { + ...(parentColumnFilters || {}), + ...(paginationParams.filters || {}), + ...sectionFilter, + }; + const pObj: Record = { + page: paginationParams.page, + pageSize: paginationParams.pageSize, + filters: mergedFilters, + groupByLevels: [], + }; + if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort; + if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search; + if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; + const params: Record = { + ..._scopeParams, + pagination: JSON.stringify(pObj), + }; + const { data } = await api.get('/api/billing/view/users/transactions', { params }); + if (data && typeof data === 'object' && 'items' in data) { + return { items: data.items, pagination: data.pagination }; + } + return { items: [], pagination: null }; + }, + [_scopeParams], + ); + const _fetchTransactionFilterValues = useCallback(async ( columnKey: string, crossFilters?: Record, @@ -518,11 +602,28 @@ export const BillingDataView: React.FC = () => { return Array.isArray(resp.data) ? resp.data : []; }, [_scopeParams]); - const transactionsHookData = useMemo(() => ({ - refetch: _loadTransactions, - pagination: transactionsPagination || undefined, - fetchFilterValues: _fetchTransactionFilterValues, - }), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]); + const transactionsHookData = useMemo( + () => ({ + refetch: _loadTransactions, + pagination: transactionsPagination || undefined, + groupLayout: transactionsGroupLayout ?? undefined, + appliedView: transactionsAppliedView ?? undefined, + fetchFilterValues: _fetchTransactionFilterValues, + fetchGroupSectionSummaries, + refetchForSection, + csvExportQueryParams: _scopeParams, + }), + [ + _loadTransactions, + transactionsPagination, + transactionsGroupLayout, + transactionsAppliedView, + _fetchTransactionFilterValues, + fetchGroupSectionSummaries, + refetchForSection, + _scopeParams, + ], + ); const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [ { key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 }, @@ -635,6 +736,7 @@ export const BillingDataView: React.FC = () => { )} +
{/* ================================================================ */} {/* Tab: Übersicht (KPI overview) */} {/* ================================================================ */} @@ -722,7 +824,7 @@ export const BillingDataView: React.FC = () => { {/* Tab: Transaktionen */} {/* ================================================================ */} {activeTab === 'transactions' && ( -
+
{transactionsError && (
{transactionsError} @@ -734,6 +836,8 @@ export const BillingDataView: React.FC = () => { data={transactions} columns={columns} apiEndpoint="/api/billing/view/users/transactions" + tableContextKey="billing/view/users/transactions" + tableGroupLayoutMode="sections" loading={transactionsLoading} pagination={true} pageSize={25} @@ -742,12 +846,13 @@ export const BillingDataView: React.FC = () => { sortable={true} selectable={false} emptyMessage={t('Keine Transaktionen vorhanden')} - onRefresh={_loadTransactions} hookData={transactionsHookData} />
)} +
+
); }; diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx index b176ff8..3e862cf 100644 --- a/src/pages/billing/BillingTransactions.tsx +++ b/src/pages/billing/BillingTransactions.tsx @@ -1,149 +1,178 @@ /** * Billing Transactions Page - * - * Zeigt die Transaktionshistorie für den Benutzer. + * + * Transaktionshistorie mit FormGeneratorTable (Suche, Filter, Sortierung, Ansichten, Gruppierung). */ -import React, { useEffect, useState } from 'react'; +import React, { useMemo } from 'react'; import { useBilling, type BillingTransaction } from '../../hooks/useBilling'; import { BillingNav } from './BillingNav'; import styles from './Billing.module.css'; - +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { useLanguage } from '../../providers/language/LanguageContext'; -// ============================================================================ -// TRANSACTION ROW COMPONENT -// ============================================================================ - -interface TransactionRowProps { - transaction: BillingTransaction; +function typePillClass(type: string): string { + switch (type) { + case 'CREDIT': + return styles.credit; + case 'DEBIT': + return styles.debit; + case 'ADJUSTMENT': + return styles.adjustment; + default: + return ''; + } } -const TransactionRow: React.FC = ({ transaction }) => { - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - - const formatDate = (dateString?: string) => { - if (!dateString) return '-'; - return new Date(dateString).toLocaleString('de-CH', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); - }; - - const getTypeClass = (type: string) => { - switch (type) { - case 'CREDIT': return styles.credit; - case 'DEBIT': return styles.debit; - case 'ADJUSTMENT': return styles.adjustment; - default: return ''; - } - }; - - const getTypeLabel = (type: string) => { - switch (type) { - case 'CREDIT': return 'Gutschrift'; - case 'DEBIT': return 'Belastung'; - case 'ADJUSTMENT': return 'Korrektur'; - default: return type; - } - }; - - return ( - - {formatDate(transaction.sysCreatedAt)} - {transaction.mandateName || '-'} - - - {getTypeLabel(transaction.transactionType)} - - - {transaction.description} - {transaction.aicoreProvider || '-'} - {transaction.aicoreModel || '-'} - {transaction.featureCode || '-'} - - {transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)} - - - ); -}; - -// ============================================================================ -// MAIN COMPONENT -// ============================================================================ +function typeLabel(type: string, t: (k: string) => string): string { + switch (type) { + case 'CREDIT': + return t('Gutschrift'); + case 'DEBIT': + return t('Belastung'); + case 'ADJUSTMENT': + return t('Korrektur'); + default: + return type; + } +} export const BillingTransactions: React.FC = () => { const { t } = useLanguage(); - const { transactions, loading, loadTransactions } = useBilling(); - const [limit, setLimit] = useState(50); - - useEffect(() => { - loadTransactions(limit); - }, [limit, loadTransactions]); - - const handleLoadMore = () => { - setLimit(prev => prev + 50); - }; - + const { + transactions, + loading, + refetchTransactions, + transactionsPagination, + transactionsGroupLayout, + transactionsAppliedView, + } = useBilling(); + + const columns = useMemo((): ColumnConfig[] => { + const fmtChf = (amount: number) => + new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); + + return [ + { + key: 'sysCreatedAt', + label: t('Datum'), + type: 'date', + sortable: true, + filterable: false, + searchable: true, + width: 170, + }, + { + key: 'mandateName', + label: t('Mandant'), + type: 'string', + sortable: true, + filterable: true, + searchable: true, + width: 160, + }, + { + key: 'transactionType', + label: t('Typ'), + type: 'string', + sortable: true, + filterable: true, + searchable: true, + width: 130, + formatter: (_v, row: BillingTransaction) => ( + + {typeLabel(row.transactionType, t)} + + ), + }, + { + key: 'description', + label: t('Beschreibung'), + type: 'string', + sortable: true, + filterable: true, + searchable: true, + minWidth: 180, + }, + { + key: 'aicoreProvider', + label: t('Anbieter'), + type: 'string', + sortable: true, + filterable: true, + searchable: true, + width: 120, + }, + { + key: 'aicoreModel', + label: t('Modell'), + type: 'string', + sortable: true, + filterable: true, + searchable: true, + width: 120, + }, + { + key: 'featureCode', + label: t('Feature'), + type: 'string', + sortable: true, + filterable: true, + searchable: true, + width: 110, + }, + { + key: 'amount', + label: t('Betrag'), + type: 'number', + sortable: true, + filterable: true, + width: 120, + formatter: (v, row: BillingTransaction) => { + const n = Number(v); + const abs = fmtChf(Math.abs(n)); + const prefix = row.transactionType === 'DEBIT' ? '-' : '+'; + return ( + + {prefix} + {abs} + + ); + }, + }, + ]; + }, [t]); + return (

{t('Transaktionen')}

{t('Übersicht aller Kontobewegungen')}

- + - +
- {loading && transactions.length === 0 ? ( -
{t('Transaktionen laden')}
- ) : transactions.length === 0 ? ( -
{t('Keine Transaktionen vorhanden')}
- ) : ( - <> -
- - - - - - - - - - - - - - - {transactions.map((transaction) => ( - - ))} - -
Datum{t('Mandant')}Typ{t('Beschreibung')}AnbieterModellFeature{t('Betrag')}
-
- - {transactions.length >= limit && ( -
- -
- )} - - )} + + data={transactions} + columns={columns} + apiEndpoint="/api/billing/transactions" + tableContextKey="billing/transactions" + loading={loading} + pagination={true} + pageSize={25} + searchable={true} + filterable={true} + sortable={true} + selectable={false} + hookData={{ + refetch: refetchTransactions, + pagination: transactionsPagination ?? undefined, + groupLayout: transactionsGroupLayout ?? undefined, + appliedView: transactionsAppliedView ?? undefined, + }} + emptyMessage={t('Keine Transaktionen vorhanden')} + />
); diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index 05f526a..bda2dba 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -20,8 +20,6 @@ import { ToolActivityLog } from './ToolActivityLog'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar'; import api from '../../../api'; -import { collectGroupItemIds } from '../../../api/fileApi'; -import type { TableGroupNode } from '../../../api/connectionApi'; import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector'; import { useBilling } from '../../../hooks/useBilling'; @@ -83,8 +81,6 @@ export const WorkspacePage: React.FC = ({ persistentInstance const [udbTab, setUdbTab] = useState('chats'); const [selectedFileId, setSelectedFileId] = useState(null); const workspaceInputRef = useRef(null); - /** Persisted grouping tree from /api/files/list — resolves dropped groups → file IDs */ - const [filesListGroupTree, setFilesListGroupTree] = useState([]); const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); const { allowedProviders } = useBilling(); const [isDragOver, setIsDragOver] = useState(false); @@ -115,27 +111,6 @@ export const WorkspacePage: React.FC = ({ persistentInstance } }, [isMobile]); - const _pullFilesGroupTree = useCallback(async (): Promise => { - if (!instanceId) return []; - try { - const res = await api.get<{ groupTree?: TableGroupNode[] }>('/api/files/list', { - params: { page: 1, pageSize: 1 }, - }); - const gt = res.data?.groupTree; - const list = Array.isArray(gt) ? gt : []; - setFilesListGroupTree(list); - return list; - } catch { - setFilesListGroupTree([]); - return []; - } - }, [instanceId]); - - useEffect(() => { - _pullFilesGroupTree(); - }, [_pullFilesGroupTree]); - - useEffect(() => { if (autoStartHandled.current || !instanceId || workspace.isProcessing) return; const prompt = searchParams.get('prompt'); @@ -153,20 +128,15 @@ export const WorkspacePage: React.FC = ({ persistentInstance }, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]); const _resolveTreeItemsToFileIds = useCallback(async (items: TreeItemDrop[]) => { - let tree = filesListGroupTree; - if (items.some(i => i.type === 'group')) { - tree = await _pullFilesGroupTree(); - } const out: string[] = []; for (const it of items) { - if (it.type === 'group') { - out.push(...collectGroupItemIds(tree, it.id)); - } else { + // Group drops are no longer supported — groups are now presentation-only (view-based) + if (it.type !== 'group') { out.push(it.id); } } return [...new Set(out)]; - }, [filesListGroupTree, _pullFilesGroupTree]); + }, []); const _uploadAndAttach = useCallback(async (file: File) => { const result = await fileOps.handleFileUpload(file, undefined, instanceId);