311 lines
9.9 KiB
TypeScript
311 lines
9.9 KiB
TypeScript
/**
|
||
* 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;
|