frontend_nyla/src/pages/views/trustee/TrusteeDataTablesView.tsx
2026-04-21 23:49:50 +02:00

347 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

/**
* TrusteeDataTablesView
*
* Consolidated "Daten-Tabellen" page that exposes every Trustee table in
* its own tab using `FormGeneratorTable` (pagination, sort, filter, search).
*
* Architecture:
* - One generic body component (`TrusteeDataTab`) is reused for the simple
* CRUD tables (Organisation, Rolle, Zugriff, Vertrag) and the read-only
* sync tables (TrusteeData*, TrusteeAccounting*).
* - For "Position" and "Dokument" tabs we embed the existing specialised
* views (`TrusteePositionsView`, `TrusteeDocumentsView`) directly, because
* they already implement the full action set:
* - Positionen: edit / delete / sync-to-accounting / Beleg-Download (1-2)
* - Dokumente: edit / delete / Download
* That way RBAC, optimistic updates, batch sync and download stay in one
* place.
* - Each tab is a thin wrapper that calls its own hook so React hook rules
* are respected and inactive tabs perform zero data fetching (lazy-mount).
* - Tab state lives in `?tab=<key>` so deep links from QuickActions /
* notifications / docs stay stable.
*
* Layout / sizing: see `wiki/b-reference/frontend-nyla/formgenerator.md`
* ("Page Layout Chain"). Outer is `adminPage + adminPageFill`, active tab
* sits inside `tableContainer`, which provides the bounded height chain
* `FormGeneratorTable` requires.
*/
import React, { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
useTrusteeOrganisations,
useTrusteeOrganisationOperations,
useTrusteeRoles,
useTrusteeRoleOperations,
useTrusteeAccess,
useTrusteeAccessOperations,
useTrusteeContracts,
useTrusteeContractOperations,
useTrusteeDataAccounts,
useTrusteeDataJournalEntries,
useTrusteeDataJournalLines,
useTrusteeDataContacts,
useTrusteeDataAccountBalances,
useTrusteeAccountingConfigs,
useTrusteeAccountingSyncs,
} from '../../../hooks/useTrustee';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { TrusteeDataTab } from './dataTables/TrusteeDataTab';
import { TrusteePositionsView } from './TrusteePositionsView';
import { TrusteeDocumentsView } from './TrusteeDocumentsView';
import adminStyles from '../../admin/Admin.module.css';
// ---------------------------------------------------------------------------
// Tab definitions
// ---------------------------------------------------------------------------
interface TabDef {
id: string;
entityName: string;
label: string;
icon: string;
color: string;
readOnly: boolean;
Wrapper: React.FC<{ instanceId: string }>;
}
interface TabGroupDef {
id: string;
label: string;
color: string;
tabs: TabDef[];
}
function _buildApiEndpoint(instanceId: string, suffix: string): string {
return `/api/trustee/${instanceId}/${suffix}`;
}
// ---------------------------------------------------------------------------
// Wrappers per-tab so inactive tabs do not fetch.
// ---------------------------------------------------------------------------
// Generic CRUD wrapper: data hook + operations hook → edit/delete actions.
function _makeCrudWrapper(
useDataHook: () => any,
useOpsHook: () => any,
suffix: string,
entityLabel: string,
): React.FC<{ instanceId: string }> {
const Wrapper: React.FC<{ instanceId: string }> = ({ instanceId }) => {
const data = useDataHook();
const ops = useOpsHook();
return (
<TrusteeDataTab
hookResult={data}
operationsHook={ops}
apiEndpoint={_buildApiEndpoint(instanceId, suffix)}
readOnly={false}
entityLabel={entityLabel}
/>
);
};
Wrapper.displayName = `TrusteeDataTabCrud(${suffix})`;
return Wrapper;
}
// Read-only wrapper: data hook only, no actions.
function _makeReadOnlyWrapper(
useDataHook: () => any,
suffix: string,
): React.FC<{ instanceId: string }> {
const Wrapper: React.FC<{ instanceId: string }> = ({ instanceId }) => {
const data = useDataHook();
return (
<TrusteeDataTab
hookResult={data}
apiEndpoint={_buildApiEndpoint(instanceId, suffix)}
readOnly={true}
/>
);
};
Wrapper.displayName = `TrusteeDataTabRO(${suffix})`;
return Wrapper;
}
// Specialised wrappers: reuse existing CRUD views with full action set.
const _PositionsWrapper: React.FC<{ instanceId: string }> = () => <TrusteePositionsView />;
_PositionsWrapper.displayName = 'TrusteeDataTabPositions';
const _DocumentsWrapper: React.FC<{ instanceId: string }> = () => <TrusteeDocumentsView />;
_DocumentsWrapper.displayName = 'TrusteeDataTabDocuments';
const _OrganisationsWrapper = _makeCrudWrapper(useTrusteeOrganisations, useTrusteeOrganisationOperations, 'organisations', 'Organisation');
const _RolesWrapper = _makeCrudWrapper(useTrusteeRoles, useTrusteeRoleOperations, 'roles', 'Rolle');
const _AccessWrapper = _makeCrudWrapper(useTrusteeAccess, useTrusteeAccessOperations, 'access', 'Zugriff');
const _ContractsWrapper = _makeCrudWrapper(useTrusteeContracts, useTrusteeContractOperations, 'contracts', 'Vertrag');
const _DataAccountsWrapper = _makeReadOnlyWrapper(useTrusteeDataAccounts, 'data/accounts');
const _DataJournalEntriesWrapper = _makeReadOnlyWrapper(useTrusteeDataJournalEntries, 'data/journal-entries');
const _DataJournalLinesWrapper = _makeReadOnlyWrapper(useTrusteeDataJournalLines, 'data/journal-lines');
const _DataContactsWrapper = _makeReadOnlyWrapper(useTrusteeDataContacts, 'data/contacts');
const _DataAccountBalancesWrapper = _makeReadOnlyWrapper(useTrusteeDataAccountBalances, 'data/account-balances');
const _AccountingConfigsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingConfigs, 'accounting/configs');
const _AccountingSyncsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingSyncs, 'accounting/syncs');
// Group structure mirrors `DATA_OBJECTS` in `gateway/modules/features/trustee/mainTrustee.py`
// (UDB folders): Stammdaten · Lokale Daten · Konfiguration · Daten aus Buchhaltungssystem.
// "Stammdaten" is page-only (Organisation/Rolle/Zugriff/Vertrag are admin tables that
// don't appear in the UDB because the feature instance IS the organisation).
function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
return [
{
id: 'master',
label: t('Stammdaten'),
color: '#1976d2',
tabs: [
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
],
},
{
id: 'localData',
label: t('Lokale Daten'),
color: '#388e3c',
tabs: [
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
],
},
{
id: 'config',
label: t('Konfiguration'),
color: '#5e35b1',
tabs: [
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Verbindung'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
],
},
{
id: 'accountingData',
label: t('Daten aus Buchhaltungssystem'),
color: '#ef6c00',
tabs: [
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Kontenplan'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
],
},
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const TrusteeDataTablesView: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const [searchParams, setSearchParams] = useSearchParams();
const tabGroups = useMemo(() => _buildTabGroups(t), [t]);
const visibleTabs = useMemo(() => tabGroups.flatMap((g) => g.tabs), [tabGroups]);
const requestedTab = searchParams.get('tab');
const activeTab = useMemo(() => {
if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) {
return requestedTab;
}
return visibleTabs[0]?.id || '';
}, [requestedTab, visibleTabs]);
const _setActiveTab = useCallback((tabId: string) => {
setSearchParams({ tab: tabId }, { replace: true });
}, [setSearchParams]);
const currentTab = visibleTabs.find((tab) => tab.id === activeTab) || visibleTabs[0];
if (!instanceId) {
return (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<p>{t('Instanz wird geladen…')}</p>
</div>
);
}
if (!currentTab) {
return (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<div className={adminStyles.pageHeader}>
<div>
<h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
</div>
</div>
<p>{t('Du hast keine Berechtigung für')}</p>
</div>
);
}
const ActiveWrapper = currentTab.Wrapper;
return (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<div className={adminStyles.pageHeader}>
<div>
<h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
<p className={adminStyles.pageSubtitle}>
{t('Alle Datenbanktabellen dieser Trustee-Instanz auf einen Blick.')}
</p>
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
marginBottom: '1rem',
flexShrink: 0,
}}
>
{tabGroups.map((group) => (
<div
key={group.id}
style={{
display: 'grid',
gridTemplateColumns: '11rem 1fr',
alignItems: 'center',
gap: '0.75rem',
}}
>
<div
style={{
fontSize: '0.6875rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: group.color,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
borderLeft: `3px solid ${group.color}`,
paddingLeft: '0.5rem',
}}
title={group.label}
>
{group.label}
</div>
<div
style={{
display: 'flex',
gap: '0.25rem',
flexWrap: 'wrap',
}}
>
{group.tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => _setActiveTab(tab.id)}
title={tab.readOnly ? t('Nur lesen Daten kommen aus dem Sync.') : tab.label}
style={{
padding: '0.375rem 0.75rem',
border: `1px solid ${isActive ? tab.color : 'var(--border-color, #e0e0e0)'}`,
borderRadius: 4,
background: isActive ? `${tab.color}15` : 'transparent',
color: isActive ? tab.color : 'var(--text-secondary, #555)',
fontWeight: isActive ? 600 : 400,
fontSize: '0.8125rem',
cursor: 'pointer',
transition: 'all 0.15s',
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: '0.375rem',
}}
>
<span aria-hidden="true">{tab.icon}</span>
<span>{tab.label}</span>
{tab.readOnly && (
<span
aria-label={t('Nur lesen')}
style={{ fontSize: '0.75rem', opacity: 0.7, lineHeight: 1 }}
>
{'\uD83D\uDD12'}
</span>
)}
</button>
);
})}
</div>
</div>
))}
</div>
<div className={adminStyles.tableContainer}>
<ActiveWrapper instanceId={instanceId} />
</div>
</div>
);
};
export default TrusteeDataTablesView;