347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
/**
|
||
* 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;
|