fix: falsche gruppierung entfernt, gruppierung richtig implementiert
This commit is contained in:
parent
d42fa02736
commit
930a34662d
25 changed files with 2398 additions and 1054 deletions
|
|
@ -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<string, any>;
|
||||
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<BillingTransactionsPaginatedResponse> {
|
||||
const paginationObj: Record<string, unknown> = {};
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -55,19 +55,22 @@ export interface PaginationParams {
|
|||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
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<T> {
|
||||
|
|
@ -78,8 +81,8 @@ export interface PaginatedResponse<T> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ export interface PaginationParams {
|
|||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
groupId?: string;
|
||||
saveGroupTree?: any[];
|
||||
viewKey?: string;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -46,6 +46,8 @@ export interface PaginatedResponse<T> {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -46,8 +46,7 @@ export interface PaginationParams {
|
|||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
groupId?: string;
|
||||
saveGroupTree?: any[];
|
||||
viewKey?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ export interface PaginationParams {
|
|||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
groupId?: string;
|
||||
saveGroupTree?: any[];
|
||||
viewKey?: string;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -61,6 +61,8 @@ export interface PaginatedResponse<T> {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
59
src/api/tableViewApi.ts
Normal file
59
src/api/tableViewApi.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<TableListViewRow[]> {
|
||||
const { data } = await api.get<TableListViewRow[]>('/api/table-views', {
|
||||
params: { contextKey },
|
||||
});
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function getTableView(contextKey: string, viewKey: string): Promise<TableListViewRow> {
|
||||
const { data } = await api.get<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewKey)}`, {
|
||||
params: { contextKey },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTableView(payload: {
|
||||
contextKey: string;
|
||||
viewKey: string;
|
||||
displayName: string;
|
||||
config: TableViewConfig;
|
||||
}): Promise<TableListViewRow> {
|
||||
const { data } = await api.post<TableListViewRow>('/api/table-views', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTableView(
|
||||
viewId: string,
|
||||
updates: { displayName?: string; viewKey?: string; config?: TableViewConfig },
|
||||
): Promise<TableListViewRow> {
|
||||
const { data } = await api.put<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewId)}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTableView(viewId: string): Promise<void> {
|
||||
await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`);
|
||||
}
|
||||
|
|
@ -48,8 +48,7 @@ export interface PaginationParams {
|
|||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
groupId?: string;
|
||||
saveGroupTree?: any[];
|
||||
viewKey?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 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) && (
|
||||
<div className={styles.searchContainer}>
|
||||
{searchable && (
|
||||
<div className={styles.floatingLabelInput}>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -203,6 +202,7 @@ export function FormGeneratorControls({
|
|||
{t('Suchen...')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className={styles.activeFiltersCount}>
|
||||
{activeFiltersCount} {t('Filter')}
|
||||
|
|
@ -219,16 +219,6 @@ export function FormGeneratorControls({
|
|||
{csvExporting ? t('Exportiere...') : 'CSV'}
|
||||
</button>
|
||||
)}
|
||||
{groupingEnabled && onCreateGroup && (
|
||||
<button
|
||||
onClick={onCreateGroup}
|
||||
className={styles.refreshButton}
|
||||
title={t('Neue Gruppe erstellen')}
|
||||
style={{ color: activeGroupId ? 'var(--color-primary, #4a6fa5)' : undefined }}
|
||||
>
|
||||
<span className={styles.refreshIcon}><FaLayerGroup /></span>
|
||||
</button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
/* Outer table in “sections” mode: fill flex parent (e.g. billing transactions tab) */
|
||||
.formGeneratorTableSectionsRoot {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
|
|
@ -79,6 +85,93 @@
|
|||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
/* ── Group sections layout (one table per category) ───────────────────── */
|
||||
.groupSections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.groupSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
/* Share remaining viewport among expanded groups; scroll when many groups */
|
||||
flex: 1 1 280px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.groupSectionCollapsed {
|
||||
flex: 0 0 auto;
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.groupSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 8px 4px 4px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.groupSectionHeader:hover {
|
||||
background: color-mix(in srgb, var(--color-bg, #fff) 92%, var(--color-border, #e2e8f0) 8%);
|
||||
}
|
||||
|
||||
.groupSectionHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupSectionCaret {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.groupSectionTitle {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text, inherit);
|
||||
}
|
||||
|
||||
.groupSectionMeta {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #64748b);
|
||||
}
|
||||
|
||||
.groupSectionsLoading {
|
||||
padding: 12px 4px;
|
||||
color: var(--text-muted, #64748b);
|
||||
}
|
||||
|
||||
.groupSectionTableWrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
|
|
@ -1237,3 +1330,69 @@ tbody .actionsColumn {
|
|||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Group bands (server-side view grouping — ClickUp-style) */
|
||||
.groupBandHeaderRow {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: color-mix(in srgb, var(--color-bg, #fff) 88%, var(--color-border, #e2e8f0) 12%);
|
||||
}
|
||||
|
||||
.groupBandHeaderCell {
|
||||
padding: 8px 14px !important;
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.groupBandInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #0f172a);
|
||||
}
|
||||
|
||||
.groupBandCaret {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.groupBandPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: min(420px, 72%);
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.25;
|
||||
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 16%, transparent);
|
||||
color: color-mix(in srgb, var(--color-primary, #2f4364) 95%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 32%, transparent);
|
||||
}
|
||||
|
||||
.groupBandPath {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.groupBandPathSep {
|
||||
opacity: 0.45;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.groupBandCount {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal file
337
src/components/FormGenerator/TableViewsBar/TableViewsBar.tsx
Normal file
|
|
@ -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<void>;
|
||||
/** When a saved view is active, overwrite its config (filters, sort, grouping, folds). Optional. */
|
||||
onSaveActiveView?: () => void | Promise<void>;
|
||||
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
|
||||
onDeleteView?: (viewId: string) => void | Promise<void>;
|
||||
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<void>,
|
||||
) {
|
||||
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<HTMLDivElement>(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<GroupByLevelSpec>) => {
|
||||
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 (
|
||||
<div className={styles.toolbar}>
|
||||
<div ref={wrapRef} className={styles.popoverAnchor}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
|
||||
onClick={() => setGroupMenuOpen((o) => !o)}
|
||||
aria-expanded={groupMenuOpen}
|
||||
aria-label={t('Gruppieren')}
|
||||
title={t('Gruppieren')}
|
||||
>
|
||||
<FaLayerGroup className={styles.groupIcon} aria-hidden />
|
||||
</button>
|
||||
{groupMenuOpen && (
|
||||
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
|
||||
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
|
||||
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
|
||||
<div className={styles.levelList}>
|
||||
{levelsForUi.map((level, idx) => (
|
||||
<div key={idx} className={styles.levelRow}>
|
||||
<select
|
||||
className={styles.select}
|
||||
aria-label={t('Spalte')}
|
||||
value={level.field}
|
||||
onChange={(e) => updateLevel(idx, { field: e.target.value })}
|
||||
>
|
||||
<option value="">{t('Spalte wählen')}</option>
|
||||
{columnsForRow(idx, level.field).map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label || c.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.selectOrder}
|
||||
aria-label={t('Sortierung')}
|
||||
value={level.direction}
|
||||
disabled={!level.field}
|
||||
onChange={(e) =>
|
||||
updateLevel(idx, { direction: e.target.value === 'desc' ? 'desc' : 'asc' })
|
||||
}
|
||||
>
|
||||
<option value="asc">{t('Aufsteigend')}</option>
|
||||
<option value="desc">{t('Absteigend')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
title={t('Ebene entfernen')}
|
||||
aria-label={t('Ebene entfernen')}
|
||||
disabled={levelsForUi.length <= 1 && !level.field}
|
||||
onClick={() => removeLevel(idx)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className={styles.addLevelBtn} onClick={addLevelRow}>
|
||||
{t('+ Weitere Ebene')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={styles.activeSummary} title={summary}>
|
||||
{groupByLevels.filter((l) => l.field).length === 0
|
||||
? t('Nicht gruppiert')
|
||||
: `${t('Aktiv')}: ${summary}`}
|
||||
</span>
|
||||
|
||||
<div className={styles.viewBlock}>
|
||||
<span className={styles.viewLabel}>{t('Ansicht')}</span>
|
||||
<select
|
||||
className={styles.viewSelect}
|
||||
value={activeViewKey ?? ''}
|
||||
disabled={loadingViews}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onSelectView(v === '' ? null : v);
|
||||
}}
|
||||
>
|
||||
<option value="">{t('Standard')}</option>
|
||||
{views.map((v) => (
|
||||
<option key={v.id} value={v.viewKey}>
|
||||
{v.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnGhost}
|
||||
disabled={loadingViews || overwriteSaving}
|
||||
title={
|
||||
activeViewId
|
||||
? t('Aktuelle Ansicht mit Filter, Sortierung und Gruppierung überschreiben')
|
||||
: t('Neue Ansicht speichern')
|
||||
}
|
||||
onClick={() => void _onClickSave()}
|
||||
>
|
||||
{overwriteSaving ? t('Wird gespeichert…') : t('Speichern…')}
|
||||
</button>
|
||||
{activeViewId && onDeleteView && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDangerGhost}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('Diese Ansicht wirklich löschen?'))) {
|
||||
void Promise.resolve(onDeleteView(activeViewId)).then(() => onReloadViews());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Löschen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveOpen && (
|
||||
<div
|
||||
className={styles.modalBackdrop}
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setSaveOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.modal} role="dialog" aria-labelledby="new-view-title" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 id="new-view-title">{t('Neue Ansicht')}</h3>
|
||||
<p className={styles.modalHint}>{t('Übernimmt Filter, Sortierung und Gruppierung.')}</p>
|
||||
<div className={styles.modalField}>
|
||||
<label htmlFor="nv-name">{t('Anzeigename')}</label>
|
||||
<input
|
||||
id="nv-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('z. B. Nach Status')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.modalActions}>
|
||||
<button type="button" className={styles.btnGhost} onClick={() => setSaveOpen(false)}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnPrimary}
|
||||
disabled={saving || !newName.trim()}
|
||||
onClick={() => void _saveNew()}
|
||||
>
|
||||
{saving ? t('Speichern…') : t('Erstellen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
1
src/components/FormGenerator/TableViewsBar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar';
|
||||
|
|
@ -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<BillingBalance[]>([]);
|
||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
||||
const [transactionsPagination, setTransactionsPagination] = useState<{
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
|
||||
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
|
||||
viewKey?: string;
|
||||
displayName?: string;
|
||||
} | null>(null);
|
||||
const [statistics, setStatistics] = useState<UsageReport | null>(null);
|
||||
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<import('../api/connectionApi').TableGroupNode[]>([]);
|
||||
const [groupLayout, setGroupLayout] = useState<GroupLayout | null>(null);
|
||||
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const { request, isLoading, error } = useApiRequest<any, any>();
|
||||
|
|
@ -91,6 +93,69 @@ export function useConnections() {
|
|||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchGroupSectionSummaries = useCallback(
|
||||
async (base: {
|
||||
search?: string;
|
||||
filters?: Record<string, any>;
|
||||
sort?: Array<{ field: string; direction: string }>;
|
||||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
}) => {
|
||||
const pObj: Record<string, unknown> = {
|
||||
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<string, unknown>,
|
||||
parentColumnFilters?: Record<string, unknown>,
|
||||
) => {
|
||||
const mergedFilters = {
|
||||
...(parentColumnFilters || {}),
|
||||
...(paginationParams.filters || {}),
|
||||
...sectionFilter,
|
||||
};
|
||||
const pObj: Record<string, unknown> = {
|
||||
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<Connection[]> => {
|
||||
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<Connection>).groupLayout ?? null);
|
||||
setAppliedView((data as PaginatedResponse<Connection>).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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
}
|
||||
|
||||
// Files list hook
|
||||
|
|
@ -82,7 +82,8 @@ export function useUserFiles() {
|
|||
totalItems: number;
|
||||
totalPages: number;
|
||||
} | null>(null);
|
||||
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
|
||||
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
|
||||
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
|
|
@ -140,6 +141,69 @@ export function useUserFiles() {
|
|||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchGroupSectionSummaries = useCallback(
|
||||
async (base: {
|
||||
search?: string;
|
||||
filters?: Record<string, any>;
|
||||
sort?: Array<{ field: string; direction: string }>;
|
||||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
}) => {
|
||||
const pObj: Record<string, unknown> = {
|
||||
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<string, unknown>,
|
||||
parentColumnFilters?: Record<string, unknown>,
|
||||
) => {
|
||||
const mergedFilters = {
|
||||
...(parentColumnFilters || {}),
|
||||
...(paginationParams.filters || {}),
|
||||
...sectionFilter,
|
||||
};
|
||||
const pObj: Record<string, unknown> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TableGroupNode[]>([]);
|
||||
const [groupLayout, setGroupLayout] = useState<import('../api/connectionApi').GroupLayout | null>(null);
|
||||
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
|
||||
const { checkPermission } = usePermissions();
|
||||
|
||||
|
|
@ -90,6 +90,69 @@ export function usePrompts() {
|
|||
}
|
||||
}, [checkPermission]);
|
||||
|
||||
const fetchGroupSectionSummaries = useCallback(
|
||||
async (base: {
|
||||
search?: string;
|
||||
filters?: Record<string, any>;
|
||||
sort?: Array<{ field: string; direction: string }>;
|
||||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
}) => {
|
||||
const pObj: Record<string, unknown> = {
|
||||
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<string, unknown>,
|
||||
parentColumnFilters?: Record<string, unknown>,
|
||||
) => {
|
||||
const mergedFilters = {
|
||||
...(parentColumnFilters || {}),
|
||||
...(paginationParams.filters || {}),
|
||||
...sectionFilter,
|
||||
};
|
||||
const pObj: Record<string, unknown> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Connection | null>(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')}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
parentColumnFilters?: Record<string, unknown>,
|
||||
) => {
|
||||
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')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(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<string, any>;
|
||||
sort?: Array<{ field: string; direction: string }>;
|
||||
viewKey?: string | null;
|
||||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
}) => {
|
||||
const pObj: Record<string, unknown> = {
|
||||
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<string, string> = {
|
||||
..._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<string, unknown>,
|
||||
parentColumnFilters?: Record<string, unknown>,
|
||||
) => {
|
||||
const mergedFilters = {
|
||||
...(parentColumnFilters || {}),
|
||||
...(paginationParams.filters || {}),
|
||||
...sectionFilter,
|
||||
};
|
||||
const pObj: Record<string, unknown> = {
|
||||
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<string, string> = {
|
||||
..._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<string, any>,
|
||||
|
|
@ -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 = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.billingTabBody}>
|
||||
{/* ================================================================ */}
|
||||
{/* Tab: Übersicht (KPI overview) */}
|
||||
{/* ================================================================ */}
|
||||
|
|
@ -722,7 +824,7 @@ export const BillingDataView: React.FC = () => {
|
|||
{/* Tab: Transaktionen */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === 'transactions' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '500px' }}>
|
||||
<div className={styles.transactionsTabLayout}>
|
||||
{transactionsError && (
|
||||
<div className={styles.errorMessage}>
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<TransactionRowProps> = ({ 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 (
|
||||
<tr>
|
||||
<td>{formatDate(transaction.sysCreatedAt)}</td>
|
||||
<td>{transaction.mandateName || '-'}</td>
|
||||
<td>
|
||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
||||
{getTypeLabel(transaction.transactionType)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{transaction.description}</td>
|
||||
<td>{transaction.aicoreProvider || '-'}</td>
|
||||
<td>{transaction.aicoreModel || '-'}</td>
|
||||
<td>{transaction.featureCode || '-'}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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) => (
|
||||
<span className={`${styles.transactionType} ${typePillClass(row.transactionType)}`}>
|
||||
{typeLabel(row.transactionType, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<span style={{ display: 'block', textAlign: 'right' }}>
|
||||
{prefix}
|
||||
{abs}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard}>
|
||||
<header className={styles.pageHeader}>
|
||||
<h1>{t('Transaktionen')}</h1>
|
||||
<p className={styles.subtitle}>{t('Übersicht aller Kontobewegungen')}</p>
|
||||
</header>
|
||||
|
||||
|
||||
<BillingNav />
|
||||
|
||||
|
||||
<section className={styles.section}>
|
||||
{loading && transactions.length === 0 ? (
|
||||
<div className={styles.loadingPlaceholder}>{t('Transaktionen laden')}</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className={styles.noData}>{t('Keine Transaktionen vorhanden')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className={styles.transactionsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>{t('Mandant')}</th>
|
||||
<th>Typ</th>
|
||||
<th>{t('Beschreibung')}</th>
|
||||
<th>Anbieter</th>
|
||||
<th>Modell</th>
|
||||
<th>Feature</th>
|
||||
<th style={{ textAlign: 'right' }}>{t('Betrag')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{transactions.length >= limit && (
|
||||
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
|
||||
<button
|
||||
className={`${styles.button} ${styles.buttonSecondary}`}
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('Laden') : t('Mehr laden')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FormGeneratorTable<BillingTransaction>
|
||||
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')}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<WorkspacePageProps> = ({ persistentInstance
|
|||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||
const workspaceInputRef = useRef<WorkspaceInputHandle>(null);
|
||||
/** Persisted grouping tree from /api/files/list — resolves dropped groups → file IDs */
|
||||
const [filesListGroupTree, setFilesListGroupTree] = useState<TableGroupNode[]>([]);
|
||||
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
||||
const { allowedProviders } = useBilling();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
|
@ -115,27 +111,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const _pullFilesGroupTree = useCallback(async (): Promise<TableGroupNode[]> => {
|
||||
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<WorkspacePageProps> = ({ 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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue