ui-nyla/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
2026-04-21 23:49:50 +02:00

311 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TrusteeDataTab
*
* Generic tab body that mounts a `FormGeneratorTable` for one Trustee data
* model. The actual data hook (created via the `_createTrusteeEntityHook`
* factory in `useTrustee.ts`) is provided by the parent
* `TrusteeDataTablesView` so this component stays purely presentational.
*
* Modes:
* - `readOnly: true` (default for sync tables, TrusteeData*, TrusteeAccounting*)
* no edit/delete/select UI.
* - `readOnly: false` + `operationsHook` supplied wires up edit/delete with
* a `FormGeneratorForm` modal and respects backend RBAC permissions
* (`permissions.update`, `permissions.delete`) returned by the entity hook.
*
* Layout chain: see `wiki/b-reference/frontend-nyla/formgenerator.md`
* ("Page Layout Chain"). The parent provides `tableContainer`; this component
* propagates `flex:1; min-height:0; flex-direction:column; width:100%`.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaSync } from 'react-icons/fa';
import { FormGeneratorTable } from '../../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../../components/FormGenerator/FormGeneratorForm';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useInstanceId } from '../../../../hooks/useCurrentInstance';
import adminStyles from '../../../admin/Admin.module.css';
export interface TrusteeDataTabProps {
/** Result of the entity hook factory call (see `useTrustee.ts`). */
hookResult: any;
/** Optional result of the matching operations hook (handleDelete/Update/Create). */
operationsHook?: any;
/** Backend endpoint that backs the table (Unified Filter API enabled). */
apiEndpoint: string;
/** Read-only mode hides edit/delete/select UI. */
readOnly?: boolean;
/** Extra column keys to hide on top of the default system fields. */
hiddenColumns?: string[];
/** Optional initial sort applied on first load. */
initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>;
/** Default page size for this tab (Sync-Tabellen können > 25 wollen). */
pageSize?: number;
/** Empty-state message override. */
emptyMessage?: string;
/** Human label for the entity (used in modal title, e.g. "Organisation"). */
entityLabel?: string;
}
const _DEFAULT_HIDDEN_COLUMNS = [
'mandateId',
'featureInstanceId',
'_hideDelete',
'_permissions',
];
const _SYSTEM_FORM_FIELDS = [
'id',
'mandateId',
'instanceId',
'featureInstanceId',
'sysCreatedAt',
'sysCreatedBy',
'sysModifiedAt',
'sysModifiedBy',
];
export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
hookResult,
operationsHook,
apiEndpoint,
readOnly = true,
hiddenColumns,
initialSort,
pageSize = 25,
emptyMessage,
entityLabel,
}) => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const {
items,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchById,
updateOptimistically,
removeOptimistically,
} = hookResult;
const handleDelete = operationsHook?.handleDelete;
const handleUpdate = operationsHook?.handleUpdate;
const deletingItems: Set<string> = operationsHook?.deletingItems ?? new Set();
// Permission gating (RBAC enforced by backend; we only avoid leaking buttons)
const canUpdate = !readOnly && !!handleUpdate && permissions?.update !== 'n';
const canDelete = !readOnly && !!handleDelete && permissions?.delete !== 'n';
// Edit modal state
const [editingRow, setEditingRow] = useState<any | null>(null);
const _tableRefetch = useCallback(async (params?: any) => {
await refetch(params);
}, [refetch]);
const _refresh = useCallback(async () => {
await _tableRefetch({ page: 1, pageSize, sort: initialSort });
}, [_tableRefetch, pageSize, initialSort]);
useEffect(() => {
_tableRefetch({ page: 1, pageSize, sort: initialSort });
}, [_tableRefetch, pageSize, initialSort]);
const columns = useMemo(() => {
const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]);
return (attributes || [])
.filter((attr: any) => !hidden.has(attr.name))
.map((attr: any) => ({
key: attr.name,
label: attr.label || attr.name,
type: (attr.type as any) || 'text',
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: attr.fkSource,
fkDisplayField: attr.fkDisplayField,
frontendFormat: attr.frontendFormat,
frontendFormatLabels: attr.frontendFormatLabels,
}));
}, [attributes, hiddenColumns]);
const formAttributes = useMemo(() => {
return (attributes || []).filter((attr: any) => !_SYSTEM_FORM_FIELDS.includes(attr.name));
}, [attributes]);
const _handleEditClick = useCallback(async (row: any) => {
if (!fetchById) {
setEditingRow(row);
return;
}
const full = await fetchById(row.id);
setEditingRow(full || row);
}, [fetchById]);
const _handleDeleteRow = useCallback(async (row: any) => {
if (!handleDelete) return;
if (removeOptimistically) removeOptimistically(row.id);
const ok = await handleDelete(row.id);
if (!ok) await _tableRefetch();
}, [handleDelete, removeOptimistically, _tableRefetch]);
const _handleFormSubmit = useCallback(async (data: any) => {
if (!editingRow || !handleUpdate) return;
const result = await handleUpdate(editingRow.id, data);
if (result?.success) {
setEditingRow(null);
await _tableRefetch();
}
}, [editingRow, handleUpdate, _tableRefetch]);
const _handleInlineUpdate = useCallback(async (itemId: string, updateData: any, row: any) => {
if (!handleUpdate) return;
if (updateOptimistically) updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result?.success) await _tableRefetch();
}, [handleUpdate, updateOptimistically, _tableRefetch]);
// Layout: bounded height chain inside parent's `.tableContainer`
const _rootStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
width: '100%',
};
const _tableWrapStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
width: '100%',
};
if (error) {
return (
<div style={_rootStyle}>
<div className={adminStyles.errorContainer}>
<span className={adminStyles.errorIcon}></span>
<p className={adminStyles.errorMessage}>
{t('Fehler beim Laden: {detail}', { detail: String(error) })}
</p>
<button className={adminStyles.secondaryButton} onClick={_refresh}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
const actionButtons: any[] = [];
if (canUpdate) {
actionButtons.push({
type: 'edit' as const,
onAction: _handleEditClick,
title: t('Bearbeiten'),
});
}
if (canDelete) {
actionButtons.push({
type: 'delete' as const,
title: t('Löschen'),
loading: (row: any) => deletingItems.has(row.id),
});
}
return (
<div style={_rootStyle}>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '0.5rem',
flexShrink: 0,
}}
>
<button
className={adminStyles.secondaryButton}
onClick={_refresh}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
<div style={_tableWrapStyle}>
<FormGeneratorTable
data={items}
columns={columns}
apiEndpoint={apiEndpoint}
loading={loading}
pagination={true}
pageSize={pageSize}
searchable={true}
filterable={true}
sortable={true}
selectable={!readOnly}
initialSort={initialSort}
actionButtons={actionButtons}
onDelete={canDelete ? _handleDeleteRow : undefined}
hookData={{
refetch: _tableRefetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate: canUpdate ? _handleInlineUpdate : undefined,
updateOptimistically,
}}
emptyMessage={emptyMessage || t('Keine Daten gefunden')}
/>
</div>
{editingRow && canUpdate && (
<div className={adminStyles.modalOverlay}>
<div className={adminStyles.modal}>
<div className={adminStyles.modalHeader}>
<h2 className={adminStyles.modalTitle}>
{entityLabel
? t('{label} bearbeiten', { label: entityLabel })
: t('Bearbeiten')}
</h2>
<button
className={adminStyles.modalClose}
onClick={() => setEditingRow(null)}
>
</button>
</div>
<div className={adminStyles.modalContent}>
{formAttributes.length === 0 ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingRow}
mode="edit"
onSubmit={_handleFormSubmit}
onCancel={() => setEditingRow(null)}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
instanceId={instanceId || undefined}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default TrusteeDataTab;