;
- loadingParentGroup: string | null;
- addingParentKey: string | null;
- onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
}
-const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
- node, onToggle, onEnsureFds,
- onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
- expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat,
- featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField,
- onRemoveFds, featureTree,
-}) => {
+const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
+ const { node, onToggle, ...ctx } = props;
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const chevron = node.expanded ? '\u25BE' : '\u25B8';
- const wildcardFds = featureDataSources.find(
+ const wildcardFds = ctx.featureDataSources.find(
f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter,
);
- const parentTables = (node.tables || []).filter(t => t.isParent);
- const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable);
+ const topItems = useMemo(
+ () => _buildTopFeatureTree(node.tables || []),
+ [node.tables],
+ );
return (
@@ -1363,10 +1463,9 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
{node.tableCount} {t('Tabellen')}
- {/* Dynamic Remove (left of stable trio) */}
{wildcardFds && (
{ e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
+ onClick={e => { e.stopPropagation(); ctx.onRemoveFds(wildcardFds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
@@ -1374,11 +1473,10 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
)}
- {/* ── Stable trio: chat | scope | neutralize ── */}
{
e.stopPropagation();
- onSendToChat?.({
+ ctx.onSendToChat?.({
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
objectKey: `data.feature.${node.featureCode}.*`,
@@ -1397,8 +1495,8 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
{
e.stopPropagation();
- if (wildcardFds) { onCycleScope(wildcardFds); return; }
- const newId = await onEnsureFds(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
+ if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
+ const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
}
@@ -1411,8 +1509,8 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
{
e.stopPropagation();
- if (wildcardFds) { onToggleNeutralize(wildcardFds); return; }
- const newId = await onEnsureFds(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
+ if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
+ const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
}
@@ -1425,73 +1523,24 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
- {node.expanded && node.tables && node.tables.length > 0 && (
+ {node.expanded && topItems.length > 0 && (
- {/* Parent table groups (hierarchical) */}
- {parentTables.map(pt => {
- const groupKey = `${node.featureInstanceId}-${pt.tableName}`;
- const isGroupExpanded = expandedParentGroups.has(groupKey);
- const isGroupLoading = loadingParentGroup === groupKey;
- const records = node.parentRecords[pt.tableName];
- const childTables = (node.tables || []).filter(t => t.parentTable === pt.tableName);
- const ptLabel = pt.label || pt.tableName;
-
- return (
- <_ParentGroupView
- key={groupKey}
- featureNode={node}
- parentTable={pt}
- label={ptLabel}
- expanded={isGroupExpanded}
- loading={isGroupLoading}
- records={records || null}
- childTables={childTables}
- allTables={node.tables!}
- onToggleGroup={() => onToggleParentGroup(node, pt.tableName)}
- onToggleRecord={(recordId) => onToggleParentRecord(node.featureInstanceId, pt.tableName, recordId)}
- onAddRecord={(record) => onAddParentRecord(node, record, node.tables!)}
- isRecordAdded={(recordId) => isParentRecordAdded(node.featureInstanceId, pt.tableName, recordId)}
- addingParentKey={addingParentKey}
- onSendToChat={onSendToChat}
- featureDataSources={featureDataSources}
- onCycleScope={onCycleScope}
- onToggleNeutralize={onToggleNeutralize}
- onToggleNeutralizeField={onToggleNeutralizeField}
- onRemoveFds={onRemoveFds}
- featureTree={featureTree}
- inheritedScope={wildcardFds?.scope}
- inheritedNeutralize={wildcardFds?.neutralize}
- />
- );
- })}
-
- {/* Standalone tables (not part of any hierarchy) */}
- {standaloneTables.map(table => {
- const fds = featureDataSources.find(
- f => f.featureInstanceId === node.featureInstanceId && f.tableName === table.tableName && !f.recordFilter,
- );
- return (
- <_FeatureTableRow
- key={table.objectKey}
- featureNode={node}
- table={table}
- onEnsureFds={onEnsureFds}
- onSendToChat={onSendToChat}
- fds={fds}
- onCycleScope={onCycleScope}
- onToggleNeutralize={onToggleNeutralize}
- onToggleNeutralizeField={onToggleNeutralizeField}
- onRemoveFds={onRemoveFds}
- featureTree={featureTree}
- inheritedScope={wildcardFds?.scope}
- inheritedNeutralize={wildcardFds?.neutralize}
- />
- );
- })}
+ {topItems.map((item, idx) => (
+ <_FeatureItemView
+ key={_itemKey(item, idx)}
+ featureNode={node}
+ item={item}
+ pathSegments={[]}
+ depth={1}
+ inheritedScope={wildcardFds?.scope}
+ inheritedNeutralize={wildcardFds?.neutralize}
+ {...ctx}
+ />
+ ))}
)}
- {node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
+ {node.expanded && (node.tables?.length ?? 0) === 0 && !node.loading && (
{t('(keine Tabellen)')}
@@ -1500,26 +1549,441 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
);
};
-/* ─── FeatureTableRow ────────────────────────────────────────────────── */
+function _itemKey(item: FeatureItem, idx: number): string {
+ if (item.kind === 'group') return `g:${item.objectKey}-${idx}`;
+ if (item.kind === 'parentGroup') return `p:${item.table.tableName}-${idx}`;
+ return `t:${item.table.tableName}-${idx}`;
+}
+
+/* ─── FeatureItemView (recursive — handles group / parentGroup / table) ── */
+
+interface _FeatureItemViewProps extends _FeatureActionContext {
+ featureNode: FeatureConnectionNode;
+ item: FeatureItem;
+ pathSegments: string[];
+ depth: number;
+ inheritedScope?: string;
+ inheritedNeutralize?: boolean;
+}
+
+const _FeatureItemView: React.FC<_FeatureItemViewProps> = (props) => {
+ const { featureNode, item, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
+
+ if (item.kind === 'group') {
+ return (
+ <_GroupFolderView
+ featureNode={featureNode}
+ objectKey={item.objectKey}
+ label={item.label}
+ items={item.items}
+ pathSegments={pathSegments}
+ depth={depth}
+ inheritedScope={inheritedScope}
+ inheritedNeutralize={inheritedNeutralize}
+ {...ctx}
+ />
+ );
+ }
+ if (item.kind === 'parentGroup') {
+ return (
+ <_ParentGroupView
+ featureNode={featureNode}
+ table={item.table}
+ pathSegments={pathSegments}
+ depth={depth}
+ inheritedScope={inheritedScope}
+ inheritedNeutralize={inheritedNeutralize}
+ {...ctx}
+ />
+ );
+ }
+ return (
+ <_FeatureTableRow
+ featureNode={featureNode}
+ table={item.table}
+ depth={depth}
+ onAddFeatureTable={ctx.onAddFeatureTable}
+ onSendToChat={ctx.onSendToChat}
+ featureDataSources={ctx.featureDataSources}
+ onCycleScope={ctx.onCycleScope}
+ onToggleNeutralize={ctx.onToggleNeutralize}
+ onToggleNeutralizeField={ctx.onToggleNeutralizeField}
+ onRemoveFds={ctx.onRemoveFds}
+ featureTree={ctx.featureTree}
+ inheritedScope={inheritedScope}
+ inheritedNeutralize={inheritedNeutralize}
+ />
+ );
+};
+
+/* ─── GroupFolderView (categorical folder) ───────────────────────────── */
+
+interface _GroupFolderViewProps extends _FeatureActionContext {
+ featureNode: FeatureConnectionNode;
+ objectKey: string;
+ label: string;
+ items: FeatureItem[];
+ pathSegments: string[];
+ depth: number;
+ inheritedScope?: string;
+ inheritedNeutralize?: boolean;
+}
+
+const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
+ const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
+ const [hovered, setHovered] = useState(false);
+
+ const segments = [...pathSegments, `g:${objectKey}`];
+ const pathKey = _pathKey(featureNode.featureInstanceId, segments);
+ const expanded = ctx.featureExpandedPaths.has(pathKey);
+ const chevron = expanded ? '\u25BE' : '\u25B8';
+
+ return (
+
+
ctx.onToggleFeaturePath(pathKey)}
+ onMouseEnter={() => setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ cursor: 'pointer', borderRadius: 3,
+ background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
+ transition: 'background 0.1s', userSelect: 'none',
+ }}
+ >
+
+ {chevron}
+
+ {'\uD83D\uDCC1'}
+
+ {label}
+
+
+
+ {expanded && items.length > 0 && (
+
+ {items.map((sub, idx) => (
+ <_FeatureItemView
+ key={_itemKey(sub, idx)}
+ featureNode={featureNode}
+ item={sub}
+ pathSegments={segments}
+ depth={depth + 1}
+ inheritedScope={inheritedScope}
+ inheritedNeutralize={inheritedNeutralize}
+ {...ctx}
+ />
+ ))}
+
+ )}
+
+ );
+};
+
+/* ─── ParentGroupView (parent table → list of records) ───────────────── */
+
+interface _ParentGroupViewProps extends _FeatureActionContext {
+ featureNode: FeatureConnectionNode;
+ table: FeatureTableNode;
+ pathSegments: string[];
+ depth: number;
+ inheritedScope?: string;
+ inheritedNeutralize?: boolean;
+}
+
+const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
+ const { featureNode, table, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
+ const { t } = useLanguage();
+ const [hovered, setHovered] = useState(false);
+
+ const segments = [...pathSegments, `p:${table.tableName}`];
+ const pathKey = _pathKey(featureNode.featureInstanceId, segments);
+ const expanded = ctx.featureExpandedPaths.has(pathKey);
+ const loading = ctx.featureLoadingPath === pathKey;
+ const records = ctx.featureRecordsByPath[pathKey];
+ const chevron = expanded ? '\u25BE' : '\u25B8';
+
+ const childTables = (featureNode.tables || []).filter(c => c.parentTable === table.tableName);
+
+ const _onToggle = () => {
+ const willExpand = !expanded;
+ ctx.onToggleFeaturePath(pathKey);
+ if (willExpand && !records) {
+ ctx.onLoadRecordsAtPath(featureNode, table, pathSegments);
+ }
+ };
+
+ return (
+
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ cursor: 'pointer', borderRadius: 3,
+ background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
+ transition: 'background 0.1s', userSelect: 'none',
+ }}
+ >
+
+ {loading ? _Spinner() : chevron}
+
+ {'\uD83D\uDCC2'}
+
+ {table.label || table.tableName}
+
+ {childTables.length > 0 && (
+
+ +{childTables.length} {t('Tabellen')}
+
+ )}
+
+
+ {expanded && records && records.length > 0 && (
+
+ {records.map(record => (
+ <_RecordRowView
+ key={record.id}
+ featureNode={featureNode}
+ parentTable={table}
+ record={record}
+ pathSegments={segments}
+ depth={depth + 1}
+ inheritedScope={inheritedScope}
+ inheritedNeutralize={inheritedNeutralize}
+ {...ctx}
+ />
+ ))}
+
+ )}
+
+ {expanded && records && records.length === 0 && !loading && (
+
+ {t('(keine Einträge)')}
+
+ )}
+
+ );
+};
+
+/* ─── RecordRowView (single record + recursive children when expanded) ── */
+
+interface _RecordRowViewProps extends _FeatureActionContext {
+ featureNode: FeatureConnectionNode;
+ parentTable: FeatureTableNode;
+ record: ParentRecordNode;
+ pathSegments: string[];
+ depth: number;
+ inheritedScope?: string;
+ inheritedNeutralize?: boolean;
+}
+
+const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
+ const { featureNode, parentTable, record, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
+ const { t } = useLanguage();
+ const [hovered, setHovered] = useState(false);
+
+ const segments = [...pathSegments, `r:${parentTable.tableName}:${record.id}`];
+ const pathKey = _pathKey(featureNode.featureInstanceId, segments);
+ const expanded = ctx.featureExpandedPaths.has(pathKey);
+ const chevron = expanded ? '\u25BE' : '\u25B8';
+
+ const fds = ctx.featureDataSources.find(
+ f => f.featureInstanceId === featureNode.featureInstanceId
+ && f.tableName === parentTable.tableName
+ && f.recordFilter?.id === record.id,
+ );
+
+ const childItems = useMemo(
+ () => _childrenForRecord(featureNode.tables || [], parentTable.tableName),
+ [featureNode.tables, parentTable.tableName],
+ );
+
+ const isAdded = ctx.isRecordAdded(featureNode.featureInstanceId, parentTable.tableName, record.id);
+ const addingKey = `${_pathKey(featureNode.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`;
+ const isAdding = ctx.addingRecordPath === addingKey;
+
+ const _chatPayload = {
+ featureInstanceId: featureNode.featureInstanceId,
+ featureCode: featureNode.featureCode,
+ tableName: parentTable.tableName,
+ objectKey: parentTable.objectKey,
+ label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`,
+ };
+
+ return (
+
+
ctx.onToggleFeaturePath(pathKey)}
+ onMouseEnter={() => setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ draggable
+ onDragStart={(e) => {
+ e.stopPropagation();
+ const payload = JSON.stringify({
+ featureInstanceId: featureNode.featureInstanceId,
+ featureCode: featureNode.featureCode,
+ objectKey: parentTable.objectKey,
+ label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`,
+ });
+ e.dataTransfer.setData('application/feature-source', payload);
+ e.dataTransfer.setData('text/plain', record.displayLabel);
+ e.dataTransfer.effectAllowed = 'copy';
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ cursor: 'pointer', borderRadius: 3,
+ background: fds
+ ? (hovered ? '#ede7f6' : '#7b1fa208')
+ : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
+ transition: 'background 0.1s', userSelect: 'none',
+ }}
+ title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')}
+ >
+
+ {chevron}
+
+ {'\uD83D\uDCCB'}
+
+ {record.displayLabel}
+
+
+ {/* Add record + direct children as data sources (only when not already added). */}
+ {!fds && !isAdded && (
+ {
+ e.stopPropagation();
+ ctx.onAddRecordWithChildren(featureNode, parentTable, record, pathSegments);
+ }}
+ disabled={isAdding}
+ style={{
+ background: 'none', border: 'none', cursor: isAdding ? 'wait' : 'pointer',
+ fontSize: 12, color: '#7b1fa2', padding: '0 2px', flexShrink: 0,
+ opacity: hovered ? 1 : 0.5,
+ }}
+ title={t('Datensatz + Kind-Tabellen als Quelle hinzufügen')}
+ >
+ {isAdding ? '\u23F3' : '+'}
+
+ )}
+
+ {fds && (
+ { e.stopPropagation(); ctx.onRemoveFds(fds.id); }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
+ title={t('Entfernen')}
+ >
+ {'\u2715'}
+
+ )}
+
+ { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
+ style={{
+ background: 'none', border: 'none', cursor: 'pointer',
+ fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
+ opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
+ }}
+ title={t('In Chat senden')}
+ >
+ {'\u{1F4AC}'}
+
+ {fds ? (
+ { e.stopPropagation(); ctx.onCycleScope(fds); }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
+ title={`${t('Bereich')}: ${fds.scope}`}
+ >
+ {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
+
+ ) : (
+
+ {_SCOPE_ICONS[inheritedScope || 'personal']}
+
+ )}
+ {fds ? (
+ { e.stopPropagation(); ctx.onToggleNeutralize(fds); }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
+ title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
+ >
+ {'\uD83D\uDD12'}
+
+ ) : (
+
+ {'\uD83D\uDD12'}
+
+ )}
+
+
+ {expanded && childItems.length > 0 && (
+
+ {childItems.map((sub, idx) => (
+ <_FeatureItemView
+ key={_itemKey(sub, idx)}
+ featureNode={featureNode}
+ item={sub}
+ pathSegments={segments}
+ depth={depth + 1}
+ inheritedScope={fds?.scope ?? inheritedScope}
+ inheritedNeutralize={fds?.neutralize ?? inheritedNeutralize}
+ {...ctx}
+ />
+ ))}
+
+ )}
+
+ );
+};
+
+/* ─── FeatureTableRow (leaf table) ───────────────────────────────────── */
interface _FeatureTableRowProps {
featureNode: FeatureConnectionNode;
table: FeatureTableNode;
- onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise;
+ depth: number;
+ onAddFeatureTable: (
+ node: FeatureConnectionNode,
+ table: FeatureTableNode,
+ extra?: { recordFilter?: Record; labelOverride?: string },
+ ) => Promise;
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
- fds?: UdbFeatureDataSource;
- onCycleScope?: (fds: UdbFeatureDataSource) => void;
- onToggleNeutralize?: (fds: UdbFeatureDataSource) => void;
- onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void;
- onRemoveFds?: (fdsId: string) => void;
- featureTree?: MandateGroupNode[];
+ featureDataSources: UdbFeatureDataSource[];
+ onCycleScope: (fds: UdbFeatureDataSource) => void;
+ onToggleNeutralize: (fds: UdbFeatureDataSource) => void;
+ onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
+ onRemoveFds: (fdsId: string) => void;
+ featureTree: MandateGroupNode[];
inheritedScope?: string;
inheritedNeutralize?: boolean;
}
const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
- featureNode, table, onEnsureFds, onSendToChat,
- fds, onCycleScope, onToggleNeutralize, onToggleNeutralizeField,
+ featureNode, table, depth, onAddFeatureTable, onSendToChat,
+ featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField,
onRemoveFds, featureTree, inheritedScope, inheritedNeutralize,
}) => {
const { t } = useLanguage();
@@ -1527,6 +1991,12 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
const [fieldsExpanded, setFieldsExpanded] = useState(false);
const tableLabel = table.label || table.tableName;
+ const fds = featureDataSources.find(
+ f => f.featureInstanceId === featureNode.featureInstanceId
+ && f.tableName === table.tableName
+ && !f.recordFilter,
+ );
+
const effectiveScope = fds?.scope ?? inheritedScope;
const effectiveNeutralize = fds?.neutralize ?? inheritedNeutralize ?? false;
const _chatPayload = {
@@ -1534,10 +2004,12 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
featureCode: featureNode.featureCode,
tableName: table.tableName,
objectKey: table.objectKey,
- label: table.label || table.tableName,
+ label: tableLabel,
};
- const resolvedFields = featureTree ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName) : table.fields;
+ const resolvedFields = featureTree
+ ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName)
+ : table.fields;
const neutralizedCount = fds?.neutralizeFields?.length ?? 0;
return (
@@ -1554,7 +2026,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
}}
style={{
display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
borderRadius: 3,
background: fds
? (hovered ? '#ede7f6' : '#7b1fa208')
@@ -1580,8 +2052,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
)}
- {/* Dynamic Remove (left of stable trio) */}
- {fds && onRemoveFds && (
+ {fds && (
{ e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
@@ -1591,7 +2062,6 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
)}
- {/* ── Stable trio: chat | scope | neutralize ── */}
{ e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
@@ -1606,8 +2076,8 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
{
e.stopPropagation();
- if (fds && onCycleScope) { onCycleScope(fds); return; }
- const newId = await onEnsureFds(featureNode, table);
+ if (fds) { onCycleScope(fds); return; }
+ const newId = await onAddFeatureTable(featureNode, table);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
}
@@ -1620,8 +2090,8 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
{
e.stopPropagation();
- if (fds && onToggleNeutralize) { onToggleNeutralize(fds); return; }
- const newId = await onEnsureFds(featureNode, table);
+ if (fds) { onToggleNeutralize(fds); return; }
+ const newId = await onAddFeatureTable(featureNode, table);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
}
@@ -1638,7 +2108,6 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
- {/* Expandable field sub-nodes */}
{fieldsExpanded && resolvedFields.length > 0 && (
{resolvedFields.map(field => {
@@ -1649,6 +2118,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
featureNode={featureNode}
table={table}
fieldName={field}
+ depth={depth + 1}
isNeutralized={isNeutralized || effectiveNeutralize}
fds={fds}
onToggleNeutralizeField={onToggleNeutralizeField}
@@ -1669,6 +2139,7 @@ interface _FeatureFieldRowProps {
featureNode: FeatureConnectionNode;
table: FeatureTableNode;
fieldName: string;
+ depth: number;
isNeutralized: boolean;
fds?: UdbFeatureDataSource;
onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void;
@@ -1677,7 +2148,7 @@ interface _FeatureFieldRowProps {
}
const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
- featureNode, table, fieldName, isNeutralized, fds, onToggleNeutralizeField, onSendToChat, inheritedScope,
+ featureNode, table, fieldName, depth, isNeutralized, fds, onToggleNeutralizeField, onSendToChat, inheritedScope,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
@@ -1703,7 +2174,7 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
}}
style={{
display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: 56, paddingRight: 4, paddingTop: 2, paddingBottom: 2,
+ paddingLeft: depth * 16 + 8, paddingRight: 4, paddingTop: 2, paddingBottom: 2,
borderRadius: 3,
background: isNeutralized
? (hovered ? '#f3e5f5' : '#f3e5f508')
@@ -1721,7 +2192,6 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
{fieldName}
- {/* ── Stable trio: chat | scope | neutralize ── */}
{ e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
@@ -1763,262 +2233,4 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
);
};
-/* ─── ParentGroupView (parent table → parent records) ────────────────── */
-
-interface _ParentGroupViewProps extends _FdsActionProps {
- featureNode: FeatureConnectionNode;
- parentTable: FeatureTableNode;
- label: string;
- expanded: boolean;
- loading: boolean;
- records: ParentRecordNode[] | null;
- childTables: FeatureTableNode[];
- allTables: FeatureTableNode[];
- onToggleGroup: () => void;
- onToggleRecord: (recordId: string) => void;
- onAddRecord: (record: ParentRecordNode) => void;
- isRecordAdded: (recordId: string) => boolean;
- addingParentKey: string | null;
- onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
- inheritedScope?: string;
- inheritedNeutralize?: boolean;
-}
-
-const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({
- featureNode, parentTable: _parentTable, label, expanded, loading, records, childTables, allTables,
- onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey,
- onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize,
- onToggleNeutralizeField: _onToggleNeutralizeField, onRemoveFds,
- featureTree: _featureTreeRef, inheritedScope, inheritedNeutralize,
-}) => {
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
- const chevron = expanded ? '\u25BE' : '\u25B8';
-
- return (
-
-
setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: 24, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- cursor: 'pointer', borderRadius: 3,
- background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
- transition: 'background 0.1s', userSelect: 'none',
- }}
- >
-
- {loading ? _Spinner() : chevron}
-
- {'\uD83D\uDCC2'}
-
- {label}
-
- {childTables.length > 0 && (
-
- +{childTables.length} {t('Tabellen')}
-
- )}
-
-
- {expanded && records && records.length > 0 && (
-
- {records.map(record => {
- const recordFds = featureDataSources.find(
- f => f.featureInstanceId === featureNode.featureInstanceId
- && f.recordFilter?.id === record.id,
- );
- return (
- <_ParentRecordRow
- key={record.id}
- featureNode={featureNode}
- record={record}
- childTables={childTables}
- allTables={allTables}
- onToggle={() => onToggleRecord(record.id)}
- onAdd={() => onAddRecord(record)}
- isAdded={isRecordAdded(record.id)}
- isAdding={addingParentKey === `${featureNode.featureInstanceId}-parent-${record.id}`}
- onSendToChat={onSendToChat}
- fds={recordFds}
- onCycleScope={onCycleScope}
- onToggleNeutralize={onToggleNeutralize}
- onRemoveFds={onRemoveFds}
- inheritedScope={inheritedScope}
- inheritedNeutralize={inheritedNeutralize}
- />
- );
- })}
-
- )}
-
- {expanded && records && records.length === 0 && !loading && (
-
- {t('(keine Einträge)')}
-
- )}
-
- );
-};
-
-/* ─── ParentRecordRow (single parent record + child tables info) ─────── */
-
-interface _ParentRecordRowProps {
- featureNode: FeatureConnectionNode;
- record: ParentRecordNode;
- childTables: FeatureTableNode[];
- allTables: FeatureTableNode[];
- onToggle: () => void;
- onAdd: () => void;
- isAdded: boolean;
- isAdding: boolean;
- onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
- fds?: UdbFeatureDataSource;
- onCycleScope?: (fds: UdbFeatureDataSource) => void;
- onToggleNeutralize?: (fds: UdbFeatureDataSource) => void;
- onRemoveFds?: (fdsId: string) => void;
- inheritedScope?: string;
- inheritedNeutralize?: boolean;
-}
-
-const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
- featureNode, record, childTables, allTables: _allTables,
- onToggle,
- onSendToChat, fds, onCycleScope, onToggleNeutralize, onRemoveFds,
- inheritedScope, inheritedNeutralize,
-}) => {
- const { t } = useLanguage();
- const [hovered, setHovered] = useState(false);
- const chevron = record.expanded ? '\u25BE' : '\u25B8';
-
- return (
-
-
setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- draggable
- onDragStart={(e) => {
- e.stopPropagation();
- const payload = JSON.stringify({
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- objectKey: `data.feature.${featureNode.featureCode}.${record.tableName || '*'}`,
- label: record.displayLabel,
- });
- e.dataTransfer.setData('application/feature-source', payload);
- e.dataTransfer.setData('text/plain', record.displayLabel);
- e.dataTransfer.effectAllowed = 'copy';
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 4,
- paddingLeft: 44, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
- cursor: 'pointer', borderRadius: 3,
- background: fds
- ? (hovered ? '#ede7f6' : '#7b1fa208')
- : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
- transition: 'background 0.1s', userSelect: 'none',
- }}
- title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')}
- >
-
- {chevron}
-
- {'\uD83D\uDCCB'}
-
- {record.displayLabel}
-
-
- {/* Dynamic Remove (left of stable trio) */}
- {fds && onRemoveFds && (
- { e.stopPropagation(); onRemoveFds(fds.id); }}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
- title={t('Entfernen')}
- >
- {'\u2715'}
-
- )}
-
- {/* ── Stable trio: chat | scope | neutralize ── */}
- {
- e.stopPropagation();
- onSendToChat?.({
- featureInstanceId: featureNode.featureInstanceId,
- featureCode: featureNode.featureCode,
- objectKey: `data.feature.${featureNode.featureCode}.${record.tableName || '*'}`,
- label: record.displayLabel,
- });
- }}
- style={{
- background: 'none', border: 'none', cursor: 'pointer',
- fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
- opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
- }}
- title={t('In Chat senden')}
- >
- {'\u{1F4AC}'}
-
- {fds && onCycleScope ? (
- { e.stopPropagation(); onCycleScope(fds); }}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
- title={`${t('Bereich')}: ${fds.scope}`}
- >
- {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
-
- ) : (
-
- {_SCOPE_ICONS[inheritedScope || 'personal']}
-
- )}
- {fds && onToggleNeutralize ? (
- { e.stopPropagation(); onToggleNeutralize(fds); }}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
- title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
- >
- {'\uD83D\uDD12'}
-
- ) : (
-
- {'\uD83D\uDD12'}
-
- )}
-
-
-
- {record.expanded && (
-
- {childTables.map(ct => {
- const ctLabel = ct.label || ct.tableName;
- return (
-
- {'\uD83D\uDCC4'}
- {ctLabel}
- ({ct.parentKey})
-
- );
- })}
-
- )}
-
- );
-};
-
export default SourcesTab;
diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts
index 785a519..de8eb7d 100644
--- a/src/hooks/useBilling.ts
+++ b/src/hooks/useBilling.ts
@@ -29,6 +29,8 @@ import {
type CreditAddRequest,
type CheckoutCreateRequest,
type MandateUserSummary,
+ type StatisticsRangeRequest,
+ type BillingBucketSize,
} from '../api/billingApi';
// Re-export types
@@ -41,6 +43,8 @@ export type {
AccountSummary,
CreditAddRequest,
MandateUserSummary,
+ StatisticsRangeRequest,
+ BillingBucketSize,
};
export type { TransactionType, ReferenceType } from '../api/billingApi';
@@ -91,14 +95,9 @@ export function useBilling() {
}
}, [request]);
- // Fetch statistics
- const loadStatistics = useCallback(async (
- period: 'day' | 'month' | 'year',
- year: number,
- month?: number
- ) => {
+ const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
try {
- const data = await fetchStatistics(request, period, year, month);
+ const data = await fetchStatistics(request, range);
setStatistics(data);
return data;
} catch (err) {
diff --git a/src/hooks/usePeriod.ts b/src/hooks/usePeriod.ts
new file mode 100644
index 0000000..cb12f2c
--- /dev/null
+++ b/src/hooks/usePeriod.ts
@@ -0,0 +1,116 @@
+/**
+ * usePeriod - state hook for the PeriodPicker.
+ *
+ * Persists the selection in the URL (`useSearchParams`) so navigation, page
+ * reloads and `PageManager` state preservation all keep the chosen range.
+ *
+ * URL keys (configurable via `paramKey` -> `${paramKey}Preset|From|To`):
+ * - `periodPreset` = preset.kind
+ * - `periodFrom` = ISO YYYY-MM-DD
+ * - `periodTo` = ISO YYYY-MM-DD
+ * - `periodAmount` / `periodUnit` (only for `lastN` / `nextN`)
+ */
+
+import { useCallback, useMemo } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import {
+ resolvePeriod,
+ type PeriodPreset,
+ type PeriodPresetKind,
+ type PeriodUnit,
+ type PeriodValue,
+} from '../components/PeriodPicker';
+
+const _PRESET_KINDS: PeriodPresetKind[] = [
+ 'ytd', 'lastYear', 'nextYear', 'last12Months', 'next12Months',
+ 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', 'lastN', 'nextN', 'custom',
+];
+
+const _UNITS: PeriodUnit[] = ['day', 'week', 'month', 'year'];
+
+interface UsePeriodOptions {
+ /** URL prefix for params (default: "period"). Use a unique value per page if multiple pickers coexist. */
+ paramKey?: string;
+ /** Initial preset if URL is empty. Default: { kind: 'ytd' }. */
+ defaultPreset?: PeriodPreset;
+}
+
+interface UsePeriodReturn {
+ value: PeriodValue;
+ setValue: (next: PeriodValue) => void;
+ reset: () => void;
+}
+
+function _parsePresetFromParams(params: URLSearchParams, key: string): PeriodPreset | null {
+ const kind = params.get(`${key}Preset`) as PeriodPresetKind | null;
+ if (!kind || !_PRESET_KINDS.includes(kind)) return null;
+ if (kind === 'lastN' || kind === 'nextN') {
+ const amount = parseInt(params.get(`${key}Amount`) || '', 10);
+ const unit = params.get(`${key}Unit`) as PeriodUnit | null;
+ if (!amount || amount < 1 || !unit || !_UNITS.includes(unit)) return null;
+ return { kind, amount, unit };
+ }
+ return { kind } as PeriodPreset;
+}
+
+export function usePeriod(options: UsePeriodOptions = {}): UsePeriodReturn {
+ const paramKey = options.paramKey || 'period';
+ const defaultPreset: PeriodPreset = options.defaultPreset || { kind: 'ytd' };
+
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const value: PeriodValue = useMemo(() => {
+ const parsedPreset = _parsePresetFromParams(searchParams, paramKey);
+ if (parsedPreset) {
+ if (parsedPreset.kind === 'custom') {
+ const fromDate = searchParams.get(`${paramKey}From`) || '';
+ const toDate = searchParams.get(`${paramKey}To`) || '';
+ if (fromDate && toDate) return { preset: parsedPreset, fromDate, toDate };
+ } else {
+ const r = resolvePeriod(parsedPreset);
+ return { preset: parsedPreset, fromDate: r.fromDate, toDate: r.toDate };
+ }
+ }
+ const r = resolvePeriod(defaultPreset);
+ return { preset: defaultPreset, fromDate: r.fromDate, toDate: r.toDate };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchParams, paramKey]);
+
+ const setValue = useCallback((next: PeriodValue) => {
+ setSearchParams((prev) => {
+ const params = new URLSearchParams(prev);
+ params.set(`${paramKey}Preset`, next.preset.kind);
+ if (next.preset.kind === 'lastN' || next.preset.kind === 'nextN') {
+ params.set(`${paramKey}Amount`, String(next.preset.amount));
+ params.set(`${paramKey}Unit`, next.preset.unit);
+ params.delete(`${paramKey}From`);
+ params.delete(`${paramKey}To`);
+ } else if (next.preset.kind === 'custom') {
+ params.set(`${paramKey}From`, next.fromDate);
+ params.set(`${paramKey}To`, next.toDate);
+ params.delete(`${paramKey}Amount`);
+ params.delete(`${paramKey}Unit`);
+ } else {
+ params.delete(`${paramKey}From`);
+ params.delete(`${paramKey}To`);
+ params.delete(`${paramKey}Amount`);
+ params.delete(`${paramKey}Unit`);
+ }
+ return params;
+ }, { replace: true });
+ }, [setSearchParams, paramKey]);
+
+ const reset = useCallback(() => {
+ setSearchParams((prev) => {
+ const params = new URLSearchParams(prev);
+ params.delete(`${paramKey}Preset`);
+ params.delete(`${paramKey}From`);
+ params.delete(`${paramKey}To`);
+ params.delete(`${paramKey}Amount`);
+ params.delete(`${paramKey}Unit`);
+ return params;
+ }, { replace: true });
+ }, [setSearchParams, paramKey]);
+
+ return { value, setValue, reset };
+}
diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts
index de367fd..83e0823 100644
--- a/src/hooks/useTrustee.ts
+++ b/src/hooks/useTrustee.ts
@@ -55,6 +55,21 @@ import {
createPosition as createPositionApi,
updatePosition as updatePositionApi,
deletePosition as deletePositionApi,
+ // Read-only data table API (Daten-Tabellen page)
+ fetchDataAccounts as fetchDataAccountsApi,
+ fetchDataJournalEntries as fetchDataJournalEntriesApi,
+ fetchDataJournalLines as fetchDataJournalLinesApi,
+ fetchDataContacts as fetchDataContactsApi,
+ fetchDataAccountBalances as fetchDataAccountBalancesApi,
+ fetchAccountingConfigs as fetchAccountingConfigsApi,
+ fetchAccountingSyncs as fetchAccountingSyncsApi,
+ type TrusteeDataAccount,
+ type TrusteeDataJournalEntry,
+ type TrusteeDataJournalLine,
+ type TrusteeDataContact,
+ type TrusteeDataAccountBalance,
+ type TrusteeAccountingConfigRecord,
+ type TrusteeAccountingSyncRecord,
} from '../api/trusteeApi';
export type {
@@ -569,3 +584,61 @@ export const useTrusteePositionOperations = _createTrusteeOperationsHook(positio
export { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from './useTrusteePositionDocuments';
export type { TrusteePositionDocument } from '../api/trusteeApi';
+// ============================================================================
+// READ-ONLY DATA TABLE HOOKS (Daten-Tabellen page)
+// ============================================================================
+//
+// These hooks expose synced/operational tables in read-only form. Mutations
+// would be overwritten by the next accounting sync, so create/update/delete
+// are intentionally not implemented (`_readOnlyMutator` raises if called).
+
+function _readOnlyMutator(): never {
+ throw new Error('Read-only entity: mutations are not supported via this hook.');
+}
+
+function _buildReadOnlyConfig(
+ entityName: string,
+ fetchAll: TrusteeEntityConfig['fetchAll']
+): TrusteeEntityConfig {
+ return {
+ entityName,
+ fetchAll,
+ fetchById: async () => null,
+ create: _readOnlyMutator as any,
+ update: _readOnlyMutator as any,
+ deleteItem: _readOnlyMutator as any,
+ };
+}
+
+export const useTrusteeDataAccounts = _createTrusteeEntityHook(
+ _buildReadOnlyConfig('TrusteeDataAccount', fetchDataAccountsApi)
+);
+export const useTrusteeDataJournalEntries = _createTrusteeEntityHook(
+ _buildReadOnlyConfig('TrusteeDataJournalEntry', fetchDataJournalEntriesApi)
+);
+export const useTrusteeDataJournalLines = _createTrusteeEntityHook(
+ _buildReadOnlyConfig('TrusteeDataJournalLine', fetchDataJournalLinesApi)
+);
+export const useTrusteeDataContacts = _createTrusteeEntityHook(
+ _buildReadOnlyConfig('TrusteeDataContact', fetchDataContactsApi)
+);
+export const useTrusteeDataAccountBalances = _createTrusteeEntityHook(
+ _buildReadOnlyConfig('TrusteeDataAccountBalance', fetchDataAccountBalancesApi)
+);
+export const useTrusteeAccountingConfigs = _createTrusteeEntityHook(
+ _buildReadOnlyConfig('TrusteeAccountingConfig', fetchAccountingConfigsApi)
+);
+export const useTrusteeAccountingSyncs = _createTrusteeEntityHook(
+ _buildReadOnlyConfig('TrusteeAccountingSync', fetchAccountingSyncsApi)
+);
+
+export type {
+ TrusteeDataAccount,
+ TrusteeDataJournalEntry,
+ TrusteeDataJournalLine,
+ TrusteeDataContact,
+ TrusteeDataAccountBalance,
+ TrusteeAccountingConfigRecord,
+ TrusteeAccountingSyncRecord,
+};
+
diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx
index b4043a0..7d70bce 100644
--- a/src/pages/ComplianceAuditPage.tsx
+++ b/src/pages/ComplianceAuditPage.tsx
@@ -18,9 +18,16 @@ import { useLanguage } from '../providers/language/LanguageContext';
import { useUserMandates } from '../hooks/useUserMandates';
import { useConfirm } from '../hooks/useConfirm';
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
+import {
+ PeriodPicker,
+ resolvePeriod,
+ type PeriodValue,
+} from '../components/PeriodPicker';
import styles from './ComplianceAuditPage.module.css';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
+const _DEFAULT_STATS_PRESET = { kind: 'lastN' as const, amount: 30, unit: 'day' as const };
+
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
const _CATEGORY_COLORS: Record = {
@@ -153,7 +160,10 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Tab C state ──
const [stats, setStats] = useState(null);
const [statsLoading, setStatsLoading] = useState(false);
- const [statsRange, setStatsRange] = useState(30);
+ const [statsPeriod, setStatsPeriod] = useState(() => {
+ const r = resolvePeriod(_DEFAULT_STATS_PRESET);
+ return { preset: _DEFAULT_STATS_PRESET, fromDate: r.fromDate, toDate: r.toDate };
+ });
// ── Tab D: Neutralization Mappings state ──
const [neutEntries, setNeutEntries] = useState([]);
@@ -254,12 +264,12 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Tab C loader ──
- const _loadStats = useCallback(async (days = 30) => {
+ const _loadStats = useCallback(async (range: { dateFrom: string; dateTo: string }) => {
if (!selectedMandateId) return;
setStatsLoading(true);
try {
const { data } = await api.get('/api/audit/stats', {
- params: { timeRange: days },
+ params: { dateFrom: range.dateFrom, dateTo: range.dateTo },
headers: _mandateHeaders(),
});
setStats(data ?? null);
@@ -341,7 +351,7 @@ export const ComplianceAuditPage: React.FC = () => {
if (!selectedMandateId) return;
if (activeTab === 'ai-log') void _loadAiLog();
else if (activeTab === 'audit-log') void _loadAuditLog();
- else if (activeTab === 'stats') void _loadStats(statsRange);
+ else if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate });
else if (activeTab === 'neutralization') void _loadNeutMappings();
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -641,15 +651,20 @@ export const ComplianceAuditPage: React.FC = () => {
{activeTab === 'stats' && (
- {[7, 30, 90].map(d => (
-
{ setStatsRange(d); void _loadStats(d); }}
- >
- {t('{n} Tage', { n: String(d) })}
-
- ))}
+
{
+ setStatsPeriod(next);
+ void _loadStats({ dateFrom: next.fromDate, dateTo: next.toDate });
+ }}
+ direction="past"
+ defaultPreset={_DEFAULT_STATS_PRESET}
+ enabledPresets={[
+ 'lastN', 'last12Months', 'lastYear',
+ 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
+ 'ytd', 'custom',
+ ]}
+ />
{statsLoading ? (
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx
index aa1baac..03c4b45 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -10,15 +10,15 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
// Trustee Views
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
-import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
-import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
+// Note: TrusteePositionsView/TrusteeDocumentsView are no longer mounted directly here -
+// they live as tabs inside TrusteeDataTablesView (and that file imports them).
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
-import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
-import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
+import { TrusteeImportProcessView } from './views/trustee/TrusteeImportProcessView';
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
+import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
// Chatbot Views
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
@@ -121,11 +121,9 @@ type ViewComponent = React.FC;
const VIEW_COMPONENTS: Record
> = {
trustee: {
dashboard: TrusteeDashboardView,
- documents: TrusteeDocumentsView,
- positions: TrusteePositionsView,
+ 'data-tables': TrusteeDataTablesView,
'instance-roles': TrusteeInstanceRolesView,
- 'expense-import': TrusteeExpenseImportView,
- 'scan-upload': TrusteeScanUploadView,
+ 'import-process': TrusteeImportProcessView,
settings: TrusteeAccountingSettingsView,
analyse: TrusteeAnalyseView,
abschluss: TrusteeAbschlussView,
diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx
index f781f22..49270ec 100644
--- a/src/pages/admin/AdminDatabaseHealthPage.tsx
+++ b/src/pages/admin/AdminDatabaseHealthPage.tsx
@@ -10,7 +10,7 @@
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
-import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle } from 'react-icons/fa';
+import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@@ -44,6 +44,10 @@ interface OrphanEntry {
targetTable: string;
targetColumn: string;
orphanCount: number;
+ sourceRowCount?: number;
+ targetRowCount?: number;
+ targetEmpty?: boolean;
+ wouldDeleteAll?: boolean;
}
interface CleanResult {
@@ -52,6 +56,7 @@ interface CleanResult {
column: string;
deleted: number;
error?: string;
+ skipped?: string;
}
interface PaginationParams {
@@ -367,6 +372,7 @@ const OrphansTab: React.FC = () => {
const [allOrphans, setAllOrphans] = useState([]);
const [loading, setLoading] = useState(false);
const [cleaning, setCleaning] = useState(null);
+ const [downloading, setDownloading] = useState(null);
const [cleaningAll, setCleaningAll] = useState(false);
const [onlyProblems, setOnlyProblems] = useState(true);
const [dbFilter, setDbFilter] = useState('');
@@ -404,46 +410,131 @@ const OrphansTab: React.FC = () => {
const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]);
- const _cleanOne = async (o: OrphanEntry) => {
- const ok = await confirm(
- t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn }),
- { title: t('Orphans bereinigen'), variant: 'danger' },
- );
- if (!ok) return;
- const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
- setCleaning(key);
+ const _postCleanOne = async (o: OrphanEntry, force: boolean): Promise => {
try {
const res = await api.post('/api/admin/database-health/orphans/clean', {
db: o.sourceDb,
table: o.sourceTable,
column: o.sourceColumn,
+ force,
});
- toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: res.data.deleted }));
+ return res.data.deleted as number;
+ } catch (err: any) {
+ const status = err?.response?.status;
+ const detail = err?.response?.data?.detail;
+ if (status === 409 && detail?.refused) {
+ return 'refused';
+ }
+ const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Bereinigen'));
+ throw new Error(reason);
+ }
+ };
+
+ const _downloadOne = async (o: OrphanEntry) => {
+ const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
+ setDownloading(key);
+ try {
+ const res = await api.get('/api/admin/database-health/orphans/list', {
+ params: {
+ db: o.sourceDb,
+ table: o.sourceTable,
+ column: o.sourceColumn,
+ limit: 5000,
+ },
+ });
+ const payload = {
+ sourceDb: o.sourceDb,
+ sourceTable: o.sourceTable,
+ sourceColumn: o.sourceColumn,
+ targetDb: o.targetDb,
+ targetTable: o.targetTable,
+ targetColumn: o.targetColumn,
+ scannedOrphanCount: o.orphanCount,
+ downloadedAt: new Date().toISOString(),
+ ...res.data,
+ };
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ a.href = url;
+ a.download = `orphans_${o.sourceDb}_${o.sourceTable}_${o.sourceColumn}_${ts}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ toast.showSuccess(t('{count} verwaiste Datensätze heruntergeladen', { count: res.data?.count ?? 0 }));
+ } catch (err: any) {
+ const detail = err?.response?.data?.detail;
+ const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Download'));
+ toast.showError(reason);
+ } finally {
+ setDownloading(null);
+ }
+ };
+
+ const _cleanOne = async (o: OrphanEntry) => {
+ const baseMsg = t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn });
+ const warning = (o.targetEmpty || o.wouldDeleteAll)
+ ? '\n\n' + t('WARNUNG: Target-Tabelle {target} ist leer oder die Bereinigung würde alle Source-Zeilen löschen. Das ist meist eine Fehlkonfiguration!', { target: `${o.targetDb}.${o.targetTable}` })
+ : '';
+ const ok = await confirm(baseMsg + warning, { title: t('Orphans bereinigen'), variant: 'danger' });
+ if (!ok) return;
+ const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
+ setCleaning(key);
+ try {
+ let result = await _postCleanOne(o, false);
+ if (result === 'refused') {
+ const forceOk = await confirm(
+ t('Sicherheits-Check ausgelöst (leere Target-Tabelle oder >50% der Source würden gelöscht). Trotzdem mit force=true bereinigen?'),
+ { title: t('Bereinigung erzwingen?'), variant: 'danger' },
+ );
+ if (!forceOk) {
+ toast.showInfo(t('Bereinigung abgebrochen'));
+ return;
+ }
+ result = await _postCleanOne(o, true);
+ }
+ toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: result as number }));
_fetchOrphans();
} catch (err: any) {
- toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
+ toast.showError(err?.message || t('Fehler beim Bereinigen'));
} finally {
setCleaning(null);
}
};
- const _cleanAll = async () => {
+ const _cleanAll = async (force: boolean = false) => {
const ok = await confirm(
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
count: totalOrphans,
relations: allOrphans.filter(o => o.orphanCount > 0).length,
- }),
+ }) + (force ? '\n\n' + t('FORCE-Modus: Sicherheits-Checks werden ignoriert!') : ''),
{ title: t('Alle Orphans bereinigen'), variant: 'danger' },
);
if (!ok) return;
setCleaningAll(true);
try {
- const res = await api.post('/api/admin/database-health/orphans/clean-all');
+ const res = await api.post('/api/admin/database-health/orphans/clean-all', { force });
const results: CleanResult[] = res.data.results || [];
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
const errors = results.filter(r => r.error);
+ const skipped = results.filter(r => r.skipped);
+ if (skipped.length > 0 && !force) {
+ const retryOk = await confirm(
+ t('{deleted} gelöscht. {skipped} Bereinigungen wurden vom Sicherheits-Check abgelehnt (leere Target-Tabelle oder >50% Löschung). Mit force=true erneut versuchen?', { deleted: totalDeleted, skipped: skipped.length }),
+ { title: t('Force benötigt'), variant: 'danger' },
+ );
+ if (retryOk) {
+ setCleaningAll(false);
+ await _cleanAll(true);
+ return;
+ }
+ }
if (errors.length > 0) {
- toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted: totalDeleted, errors: errors.length }));
+ toast.showWarning(t('{deleted} gelöscht, {errors} Fehler, {skipped} übersprungen', { deleted: totalDeleted, errors: errors.length, skipped: skipped.length }));
+ } else if (skipped.length > 0) {
+ toast.showWarning(t('{deleted} gelöscht, {skipped} übersprungen', { deleted: totalDeleted, skipped: skipped.length }));
} else {
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
}
@@ -550,7 +641,7 @@ const OrphansTab: React.FC = () => {
{t('Scan')}
{totalOrphans > 0 && (
-
+ _cleanAll(false)} disabled={cleaningAll || loading}>
{t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
)}
@@ -579,6 +670,14 @@ const OrphansTab: React.FC = () => {
pageSize={50}
selectable={false}
customActions={[
+ {
+ id: 'download',
+ icon: ,
+ onClick: (row: OrphanEntry) => _downloadOne(row),
+ visible: (row: OrphanEntry) => row.orphanCount > 0,
+ loading: (row: OrphanEntry) => downloading === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}`,
+ title: t('Orphan-Liste herunterladen (JSON)'),
+ },
{
id: 'clean',
icon: ,
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx
index d0a835e..6bbe742 100644
--- a/src/pages/billing/BillingAdmin.tsx
+++ b/src/pages/billing/BillingAdmin.tsx
@@ -10,7 +10,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
-import type { CheckoutCreateRequest } from '../../api/billingApi';
+import { fetchCheckoutAmounts, type CheckoutCreateRequest } from '../../api/billingApi';
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
import { useCurrentUser } from '../../hooks/useUsers';
import { SubscriptionTab } from './SubscriptionTab';
@@ -369,6 +369,28 @@ const MandateStripeTopUp: React.FC = ({ mandateId, crea
const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false);
const [localMsg, setLocalMsg] = useState(null);
+ const [allowedAmounts, setAllowedAmounts] = useState([]);
+
+ useEffect(() => {
+ let cancelled = false;
+ const _request = async (opts: any) => {
+ const res = await api.request({ url: opts.url, method: opts.method, data: opts.data, params: opts.params });
+ return res.data;
+ };
+ fetchCheckoutAmounts(_request)
+ .then(list => {
+ if (cancelled) return;
+ const sorted = [...list].sort((a, b) => a - b);
+ setAllowedAmounts(sorted);
+ if (sorted.length > 0) {
+ setAmount(String(sorted[0]));
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setAllowedAmounts([]);
+ });
+ return () => { cancelled = true; };
+ }, []);
const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -377,6 +399,10 @@ const MandateStripeTopUp: React.FC = ({ mandateId, crea
setLocalMsg('Betrag muss positiv sein');
return;
}
+ if (allowedAmounts.length > 0 && !allowedAmounts.includes(n)) {
+ setLocalMsg(t('Ungültiger Betrag. Erlaubt: {list} CHF', { list: allowedAmounts.join(', ') }));
+ return;
+ }
setBusy(true);
setLocalMsg(null);
try {
@@ -412,22 +438,34 @@ const MandateStripeTopUp: React.FC = ({ mandateId, crea
{busy ? t('Weiterleitung…') : t('Mit Stripe bezahlen')}
diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx
index c20a27d..fa6fb7c 100644
--- a/src/pages/billing/BillingDashboard.tsx
+++ b/src/pages/billing/BillingDashboard.tsx
@@ -5,11 +5,26 @@
*/
import React, { useState, useEffect, useMemo } from 'react';
-import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
+import { useBilling, type BillingBalance, type UsageReport, type BillingBucketSize } from '../../hooks/useBilling';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
+import {
+ PeriodPicker,
+ resolvePeriod,
+ daysInRange,
+ type PeriodValue,
+} from '../../components/PeriodPicker';
+
+const _DEFAULT_BILLING_PRESET = { kind: 'thisMonth' as const };
+
+function _suggestBucketSize(value: PeriodValue): BillingBucketSize {
+ const days = daysInRange(value.fromDate, value.toDate);
+ if (days <= 62) return 'day';
+ if (days <= 24 * 31) return 'month';
+ return 'year';
+}
// ============================================================================
// BALANCE CARD COMPONENT
@@ -154,37 +169,41 @@ const StatisticsChart: React.FC = ({ statistics, loading }
export const BillingDashboard: React.FC = () => {
const { t } = useLanguage();
- const {
- balances,
- statistics,
- loading,
- loadStatistics
+ const {
+ balances,
+ statistics,
+ loading,
+ loadStatistics
} = useBilling();
-
- const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
- const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
- const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
-
- // Load statistics when period changes
- useEffect(() => {
- if (selectedPeriod === 'month') {
- loadStatistics('month', selectedYear);
- } else {
- loadStatistics('year', selectedYear);
+
+ const [period, setPeriod] = useState(() => {
+ const r = resolvePeriod(_DEFAULT_BILLING_PRESET);
+ return { preset: _DEFAULT_BILLING_PRESET, fromDate: r.fromDate, toDate: r.toDate };
+ });
+ // Frontend-Heuristik fuer Default; user kann uebersteuern.
+ const [bucketSize, setBucketSize] = useState(() => _suggestBucketSize(period));
+ const [bucketUserOverridden, setBucketUserOverridden] = useState(false);
+
+ const _handlePeriodChange = (next: PeriodValue) => {
+ setPeriod(next);
+ if (!bucketUserOverridden) {
+ setBucketSize(_suggestBucketSize(next));
}
- }, [selectedPeriod, selectedYear, loadStatistics]);
-
- // Available years (current and last 2 years)
- const availableYears = useMemo(() => {
- const current = new Date().getFullYear();
- return [current, current - 1, current - 2];
- }, []);
-
- // Available months
- const availableMonths = Array.from({ length: 12 }, (_, i) => ({
- value: i + 1,
- label: String(i + 1),
- }));
+ };
+
+ useEffect(() => {
+ void loadStatistics({
+ dateFrom: period.fromDate,
+ dateTo: period.toDate,
+ bucketSize,
+ });
+ }, [period.fromDate, period.toDate, bucketSize, loadStatistics]);
+
+ const _bucketLabel = useMemo(() => ({
+ day: t('Tag'),
+ month: t('Monat'),
+ year: t('Jahr'),
+ } as Record), [t]);
return (
@@ -216,34 +235,30 @@ export const BillingDashboard: React.FC = () => {
{t('Nutzungsstatistik')}
-
setSelectedPeriod(e.target.value as 'month' | 'year')}
+
+ {t('Gruppierung')}
+ {
+ setBucketSize(e.target.value as BillingBucketSize);
+ setBucketUserOverridden(true);
+ }}
className={styles.select}
+ aria-label={t('Gruppierung')}
>
- {t('Monat')}
- {t('Jahr')}
+ {_bucketLabel.day}
+ {_bucketLabel.month}
+ {_bucketLabel.year}
- setSelectedYear(Number(e.target.value))}
- className={styles.select}
- >
- {availableYears.map((year) => (
- {year}
- ))}
-
- {selectedPeriod === 'month' && (
- setSelectedMonth(Number(e.target.value))}
- className={styles.select}
- >
- {availableMonths.map((month) => (
- {month.label}
- ))}
-
- )}
diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx
index d0ca931..0b75f69 100644
--- a/src/pages/billing/BillingDataView.tsx
+++ b/src/pages/billing/BillingDataView.tsx
@@ -11,14 +11,34 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
-import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
+import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api';
-import { useBilling } from '../../hooks/useBilling';
+import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import { useLanguage } from '../../providers/language/LanguageContext';
+import {
+ daysInRange,
+ resolvePeriod,
+ toIsoDate,
+ type PeriodValue,
+} from '../../components/PeriodPicker';
import styles from './Billing.module.css';
+const _DEFAULT_STATS_PRESET = { kind: 'thisMonth' as const };
+
+function _suggestBucketSize(fromIso: string, toIso: string): BillingBucketSize {
+ const days = daysInRange(fromIso, toIso);
+ if (days <= 62) return 'day';
+ if (days <= 24 * 31) return 'month';
+ return 'year';
+}
+
+function _initialStatsPeriod(): PeriodValue {
+ const r = resolvePeriod(_DEFAULT_STATS_PRESET);
+ return { preset: _DEFAULT_STATS_PRESET, fromDate: r.fromDate, toDate: r.toDate };
+}
+
type TranslateFn = (key: string, params?: Record
) => string;
// ============================================================================
@@ -336,14 +356,24 @@ export const BillingDataView: React.FC = () => {
return params;
}, [selectedScope, onlyMyData]);
- // Load aggregated statistics from the view/statistics route
- const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
+ const [statsPeriod, setStatsPeriod] = useState(() => _initialStatsPeriod());
+ const [statsBucketSize, setStatsBucketSize] = useState(() => {
+ const init = _initialStatsPeriod();
+ return _suggestBucketSize(init.fromDate, init.toDate);
+ });
+ const [bucketUserOverridden, setBucketUserOverridden] = useState(false);
+
+ const _loadViewStatistics = useCallback(async (
+ range: { dateFrom: string; dateTo: string; bucketSize: BillingBucketSize },
+ ) => {
try {
setStatsLoading(true);
- const params: Record = { period, year, ..._scopeParams };
- if (period === 'day' && month) {
- params.month = month;
- }
+ const params: Record = {
+ dateFrom: range.dateFrom,
+ dateTo: range.dateTo,
+ bucketSize: range.bucketSize,
+ ..._scopeParams,
+ };
const response = await api.get('/api/billing/view/statistics', { params });
setViewStats(response.data);
} catch (err: any) {
@@ -354,13 +384,26 @@ export const BillingDataView: React.FC = () => {
}
}, [_scopeParams]);
- // Handle filter changes from FormGeneratorReport (user changes period/year/month)
+ // Handle PeriodPicker change coming back from the shared `dateRangeSelector`
+ // of `FormGeneratorReport`. Prefer the full `periodValue` so we keep the
+ // original preset (e.g. `thisMonth`) instead of collapsing to `custom`.
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
- const period = filterState.period || 'month';
- const year = filterState.year || new Date().getFullYear();
- const month = filterState.month;
- _loadViewStatistics(period, year, month);
- }, [_loadViewStatistics]);
+ let next: PeriodValue | null = null;
+ if (filterState.periodValue) {
+ next = filterState.periodValue;
+ } else if (filterState.dateRange) {
+ next = {
+ preset: { kind: 'custom' },
+ fromDate: toIsoDate(filterState.dateRange.from),
+ toDate: toIsoDate(filterState.dateRange.to),
+ };
+ }
+ if (!next) return;
+ setStatsPeriod(next);
+ if (!bucketUserOverridden) {
+ setStatsBucketSize(_suggestBucketSize(next.fromDate, next.toDate));
+ }
+ }, [bucketUserOverridden]);
// Load storage volume for all accessible mandates
const _loadStorageData = useCallback(async () => {
@@ -398,13 +441,17 @@ export const BillingDataView: React.FC = () => {
}
}, [balances, selectedScope, onlyMyData]);
- // Initial data load
+ // Initial / reactive load: any change to period / bucketSize / scope reloads.
useEffect(() => {
if (activeTab === 'overview' || activeTab === 'diagrams') {
- _loadViewStatistics('month', new Date().getFullYear());
+ void _loadViewStatistics({
+ dateFrom: statsPeriod.fromDate,
+ dateTo: statsPeriod.toDate,
+ bucketSize: statsBucketSize,
+ });
_loadStorageData();
}
- }, [activeTab, _loadViewStatistics, _loadStorageData]);
+ }, [activeTab, statsPeriod.fromDate, statsPeriod.toDate, statsBucketSize, _loadViewStatistics, _loadStorageData]);
// Load transactions with pagination support + scope filter
const _loadTransactions = useCallback(async (paginationParams?: any) => {
@@ -499,14 +546,15 @@ export const BillingDataView: React.FC = () => {
return _buildDiagramSections(viewStats, chartMode, t);
}, [viewStats, chartMode, t]);
- // Period selector config (shared between overview and statistics)
- const periodSelectorConfig = useMemo(() => ({
- periods: ['month' as const, 'day' as const],
- defaultPeriod: 'month' as const,
- showYear: true,
- showMonth: true,
- defaultYear: new Date().getFullYear(),
- defaultMonth: new Date().getMonth() + 1
+ // Date-range selector config: use shared PeriodPicker via FormGeneratorReport.
+ const dateRangeSelectorConfig = useMemo(() => ({
+ enabled: true,
+ direction: 'past',
+ defaultPresetKind: 'thisMonth',
+ enabledPresets: [
+ 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
+ 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
+ ],
}), []);
// Build scope options from balances (mandates the user has access to)
@@ -627,8 +675,24 @@ export const BillingDataView: React.FC = () => {
+
+ {t('Gruppierung')}
+ {
+ setStatsBucketSize(e.target.value as BillingBucketSize);
+ setBucketUserOverridden(true);
+ }}
+ aria-label={t('Gruppierung')}
+ >
+ {t('Tag')}
+ {t('Monat')}
+ {t('Jahr')}
+
+
string): string {
switch (tabId) {
case 'year-end': return t('Jahresabschluss prüfen');
+ case 'vat': return t('MWST-Abrechnung');
+ case 'reporting': return t('Reporting Behörden');
default: return tabId;
}
}
@@ -40,6 +52,8 @@ function _tabLabel(tabId: string, t: (k: string) => string): string {
function _tabDescription(tabId: string, t: (k: string) => string): string {
switch (tabId) {
case 'year-end': return t('Automatische Prüfungen für den Jahresabschluss: Saldovalidierung, Vorjahresvergleich, gesetzliche Checks.');
+ case 'vat': return t('Vierteljährliche MWST-Abrechnung vorbereiten und validieren.');
+ case 'reporting': return t('Meldungen an Behörden vorbereiten (z. B. Lohnausweise, Sozialversicherungen).');
default: return '';
}
}
@@ -81,6 +95,11 @@ export const TrusteeAbschlussView: React.FC = () => {
const pollTimerRef = useRef(null);
const isPollingRef = useRef(false);
+ const [period, setPeriod] = useState(() => {
+ const r = resolvePeriod(_DEFAULT_PERIOD_PRESET);
+ return { preset: _DEFAULT_PERIOD_PRESET, fromDate: r.fromDate, toDate: r.toDate };
+ });
+
useEffect(() => {
if (!instanceId) return;
const _load = async () => {
@@ -104,8 +123,9 @@ export const TrusteeAbschlussView: React.FC = () => {
const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
const tabDef = _TABS.find((tabItem) => tabItem.id === tab);
- if (!tabDef) return undefined;
- return workflows.find((w) => w.tags.includes(tabDef.templateTag));
+ if (!tabDef || !tabDef.templateTag) return undefined;
+ const templateTag = tabDef.templateTag;
+ return workflows.find((w) => w.tags.includes(templateTag));
}, [workflows]);
const _stopPolling = useCallback(() => {
@@ -180,7 +200,10 @@ export const TrusteeAbschlussView: React.FC = () => {
setRunError(null);
setRunSummary(t('Workflow wird gestartet…'));
try {
- const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
+ const res = await api.post(`/api/workflows/${instanceId}/execute`, {
+ workflowId: wf.id,
+ payload: { dateFrom: period.fromDate, dateTo: period.toDate },
+ });
const rid = res?.data?.runId;
if (rid) {
setRunId(rid);
@@ -199,10 +222,11 @@ export const TrusteeAbschlussView: React.FC = () => {
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
}
- }, [activeTab, instanceId, _findWorkflow, showError, showSuccess, t]);
+ }, [activeTab, instanceId, _findWorkflow, period, showError, showSuccess, t]);
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
const currentWorkflow = _findWorkflow(activeTab);
+ const isComingSoon = !!currentTab.comingSoon;
return (
@@ -242,7 +266,14 @@ export const TrusteeAbschlussView: React.FC = () => {
{_tabDescription(activeTab, t)}
- {workflowsLoading ? (
+ {isComingSoon ? (
+
+
+ {t('In Kürze verfügbar.')} {' '}
+ {t('Diese Funktion befindet sich in Vorbereitung.')}
+
+
+ ) : workflowsLoading ? (
{t('Workflows werden geladen…')}
) : !currentWorkflow ? (
@@ -260,6 +291,18 @@ export const TrusteeAbschlussView: React.FC = () => {
+
+
+ {t('Geschäftsjahr')}
+
+
+
+
string): string {
+ switch (tabId) {
+ case 'settings': return t('Verbindungseinstellungen');
+ case 'import-data': return t('Buchhaltungsdaten importieren');
+ default: return tabId;
+ }
+}
+
export const TrusteeAccountingSettingsView: React.FC = () => {
const { t } = useLanguage();
const { instanceId } = useCurrentInstance();
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const activeTab = searchParams.get('tab') || _SETTINGS_TABS[0].id;
+ const _setActiveTab = useCallback((tab: string) => {
+ setSearchParams({ tab }, { replace: true });
+ }, [setSearchParams]);
const [connectors, setConnectors] = useState([]);
const [existingConfig, setExistingConfig] = useState(null);
@@ -47,8 +68,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
const [importJobId, setImportJobId] = useState(null);
const [clearingCache, setClearingCache] = useState(false);
const [exporting, setExporting] = useState(false);
- const [dateFrom, setDateFrom] = useState('');
- const [dateTo, setDateTo] = useState('');
+ const [importPeriod, setImportPeriod] = useState(null);
const { confirm, ConfirmDialog } = useConfirm();
useEffect(() => {
@@ -218,17 +238,44 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
{t('Buchhaltungssystem-Anbindung')}
-
- {t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
-
- {existingConfig?.configured && (
-
- {t('Verbunden:')} {existingConfig.displayLabel || existingConfig.connectorType}
-
- )}
+
+ {_SETTINGS_TABS.map((tab) => (
+ _setActiveTab(tab.id)}
+ style={{
+ padding: '0.625rem 1rem',
+ border: 'none',
+ borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
+ background: 'transparent',
+ color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
+ fontWeight: activeTab === tab.id ? 600 : 400,
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ marginBottom: '-2px',
+ }}
+ >
+ {tab.icon}
+ {_settingsTabLabel(tab.id, t)}
+
+ ))}
+
- {existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
+ {activeTab === 'settings' && (
+ <>
+
+ {t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
+
+
+ {existingConfig?.configured && (
+
+ {t('Verbunden:')} {existingConfig.displayLabel || existingConfig.connectorType}
+
+ )}
+
+ {existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
0
@@ -366,12 +413,23 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
)}
- {/* Step 4: Import Accounting Data */}
- {existingConfig?.configured && (
-
-
4
-
-
{t('Buchhaltungsdaten importieren')}
+ >
+ )}
+
+ {activeTab === 'import-data' && (
+ <>
+ {!existingConfig?.configured && (
+
+
+ {t('Bevor Sie Daten importieren können, richten Sie zuerst die Verbindung zum Buchhaltungssystem im Tab «Verbindungseinstellungen» ein.')}
+
+
+ )}
+ {existingConfig?.configured && (
+
+
0
+
+
{t('Buchhaltungsdaten importieren')}
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
@@ -425,40 +483,16 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
);
})()}
-
+
-
{t('Von (optional)')}
-
setDateFrom(e.target.value)} style={{ width: '160px' }} />
+
{t('Zeitraum (optional)')}
+
-
- {t('Bis (optional)')}
- setDateTo(e.target.value)} style={{ width: '160px' }} />
-
-
-
- {[
- { label: t('Laufendes Jahr'), from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
- {
- label: t('Letztes Jahr'),
- from: `${new Date().getFullYear() - 1}-01-01`,
- to: `${new Date().getFullYear() - 1}-12-31`,
- },
- {
- label: t('Letzter Monat'),
- from: (() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); })(),
- to: (() => { const d = new Date(); d.setDate(0); return d.toISOString().slice(0, 10); })(),
- },
- ].map(s => (
- { setDateFrom(s.from); setDateTo(s.to); }}
- >
- {s.label}
-
- ))}
@@ -472,8 +506,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
setImportJobId(null);
try {
const body: Record = {};
- if (dateFrom) body.dateFrom = dateFrom;
- if (dateTo) body.dateTo = dateTo;
+ if (importPeriod?.fromDate) body.dateFrom = importPeriod.fromDate;
+ if (importPeriod?.toDate) body.dateTo = importPeriod.toDate;
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
const newJobId: string | undefined = result?.jobId;
if (newJobId) {
@@ -576,8 +610,10 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
)}
)}
-
-
+
+
+ )}
+ >
)}
diff --git a/src/pages/views/trustee/TrusteeAnalyseView.tsx b/src/pages/views/trustee/TrusteeAnalyseView.tsx
index 2194842..b3752d4 100644
--- a/src/pages/views/trustee/TrusteeAnalyseView.tsx
+++ b/src/pages/views/trustee/TrusteeAnalyseView.tsx
@@ -7,7 +7,7 @@
* and results/status are shown inline with polling.
*/
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
@@ -15,6 +15,13 @@ import api from '../../../api';
import styles from './TrusteeViews.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { FaUpload, FaTimes } from 'react-icons/fa';
+import {
+ PeriodPicker,
+ resolvePeriod,
+ type PeriodDirection,
+ type PeriodPreset,
+ type PeriodValue,
+} from '../../../components/PeriodPicker';
// ---------------------------------------------------------------------------
// Tab definitions
@@ -54,6 +61,29 @@ function _tabDescription(tabId: string, t: (k: string) => string): string {
}
}
+interface TabPeriodConfig {
+ defaultPreset: PeriodPreset;
+ direction: PeriodDirection;
+}
+
+function _periodConfigForTab(tabId: string): TabPeriodConfig {
+ switch (tabId) {
+ case 'forecast':
+ return { defaultPreset: { kind: 'next12Months' }, direction: 'future' };
+ case 'budget':
+ case 'kpi':
+ case 'cashflow':
+ default:
+ return { defaultPreset: { kind: 'ytd' }, direction: 'any' };
+ }
+}
+
+function _initialPeriodForTab(tabId: string): PeriodValue {
+ const cfg = _periodConfigForTab(tabId);
+ const r = resolvePeriod(cfg.defaultPreset);
+ return { preset: cfg.defaultPreset, fromDate: r.fromDate, toDate: r.toDate };
+}
+
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -99,6 +129,18 @@ export const TrusteeAnalyseView: React.FC = () => {
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef
(null);
+ // One PeriodValue per tab, defaults derived from `_periodConfigForTab`.
+ const [periodByTab, setPeriodByTab] = useState>(() => {
+ const initial: Record = {};
+ for (const tab of _TABS) initial[tab.id] = _initialPeriodForTab(tab.id);
+ return initial;
+ });
+ const tabPeriodConfig = useMemo(() => _periodConfigForTab(activeTab), [activeTab]);
+ const currentPeriod = periodByTab[activeTab] || _initialPeriodForTab(activeTab);
+ const _setCurrentPeriod = useCallback((next: PeriodValue) => {
+ setPeriodByTab((prev) => ({ ...prev, [activeTab]: next }));
+ }, [activeTab]);
+
// Load workflows for this instance once
useEffect(() => {
if (!instanceId) return;
@@ -266,9 +308,14 @@ export const TrusteeAnalyseView: React.FC = () => {
setResultDocuments([]);
try {
const executeBody: Record = { workflowId: wf.id };
+ const payload: Record = {
+ dateFrom: currentPeriod.fromDate,
+ dateTo: currentPeriod.toDate,
+ };
if (activeTab === 'budget' && budgetFileId) {
- executeBody.payload = { documentList: [budgetFileId] };
+ payload.documentList = [budgetFileId];
}
+ executeBody.payload = payload;
const res = await api.post(`/api/workflows/${instanceId}/execute`, executeBody);
const rid = res?.data?.runId;
if (rid) {
@@ -291,7 +338,7 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
}
- }, [activeTab, instanceId, _findWorkflow, budgetFileId, showError, showSuccess, t]);
+ }, [activeTab, instanceId, _findWorkflow, budgetFileId, currentPeriod, showError, showSuccess, t]);
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
const currentWorkflow = _findWorkflow(activeTab);
@@ -396,6 +443,18 @@ export const TrusteeAnalyseView: React.FC = () => {
)}
+
+
+ {t('Zeitraum')}
+
+
+
+
{
const navigate = useNavigate();
const { mandateId } = useParams<{ mandateId: string }>();
- const { instance, instanceId } = useCurrentInstance();
+ const { mandate, instance, instanceId } = useCurrentInstance();
const { items: positions, loading: posLoading } = useTrusteePositions();
const { items: documents, loading: docsLoading } = useTrusteeDocuments();
const { request } = useApiRequest();
@@ -136,7 +136,7 @@ export const TrusteeDashboardView: React.FC = () => {
{instance?.userRoles?.length ? (
instance.userRoles.map((role: string, idx: number) => (
-
{role}
+
{t(role)}
))
) : '-'}
@@ -162,15 +162,17 @@ export const TrusteeDashboardView: React.FC = () => {
{instance?.instanceLabel}
- {t('Mandant')}
- {instance?.mandateName}
+ {t('Mandant:')}
+
+ {mandate?.label || instance?.mandateLabel || mandate?.name || instance?.mandateName || '-'}
+
{accountingConfig?.configured && (
{t('Buchhaltungssystem:')}
{accountingConfig.displayLabel || accountingConfig.connectorType}
- {accountingConfig.lastSyncStatus && ` (${accountingConfig.lastSyncStatus})`}
+ {accountingConfig.lastSyncStatus && ` (${t(accountingConfig.lastSyncStatus)})`}
)}
diff --git a/src/pages/views/trustee/TrusteeDataTablesView.tsx b/src/pages/views/trustee/TrusteeDataTablesView.tsx
new file mode 100644
index 0000000..cf86671
--- /dev/null
+++ b/src/pages/views/trustee/TrusteeDataTablesView.tsx
@@ -0,0 +1,271 @@
+/**
+ * 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=
` 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 }>;
+}
+
+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 (
+
+ );
+ };
+ 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 (
+
+ );
+ };
+ Wrapper.displayName = `TrusteeDataTabRO(${suffix})`;
+ return Wrapper;
+}
+
+// Specialised wrappers: reuse existing CRUD views with full action set.
+const _PositionsWrapper: React.FC<{ instanceId: string }> = () => ;
+_PositionsWrapper.displayName = 'TrusteeDataTabPositions';
+const _DocumentsWrapper: React.FC<{ instanceId: string }> = () => ;
+_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');
+
+function _buildTabs(t: (k: string) => string): TabDef[] {
+ return [
+ { 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: '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: 'accounts', entityName: 'TrusteeDataAccount', label: t('Konten (Sync)'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
+ { id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen (Sync)'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
+ { id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen (Sync)'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
+ { id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte (Sync)'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
+ { id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden (Sync)'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
+ { id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Konfiguration'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
+ { id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Buchhaltungs-Synchronisation'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export const TrusteeDataTablesView: React.FC = () => {
+ const { t } = useLanguage();
+ const instanceId = useInstanceId();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const tabs = useMemo(() => _buildTabs(t), [t]);
+ const visibleTabs = tabs;
+
+ const requestedTab = searchParams.get('tab');
+ const activeTab = useMemo(() => {
+ if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) {
+ return requestedTab;
+ }
+ return visibleTabs[0]?.id || tabs[0].id;
+ }, [requestedTab, visibleTabs, tabs]);
+
+ const _setActiveTab = useCallback((tabId: string) => {
+ setSearchParams({ tab: tabId }, { replace: true });
+ }, [setSearchParams]);
+
+ const currentTab = visibleTabs.find((tab) => tab.id === activeTab) || visibleTabs[0];
+
+ if (!instanceId) {
+ return (
+
+
{t('Instanz wird geladen…')}
+
+ );
+ }
+
+ if (!currentTab) {
+ return (
+
+
+
+
{t('Daten-Tabellen')}
+
+
+
{t('Du hast keine Berechtigung für')}
+
+ );
+ }
+
+ const ActiveWrapper = currentTab.Wrapper;
+
+ return (
+
+
+
+
{t('Daten-Tabellen')}
+
+ {t('Alle Datenbanktabellen dieser Trustee-Instanz auf einen Blick.')}
+
+
+
+
+
+ {visibleTabs.map((tab) => (
+ _setActiveTab(tab.id)}
+ style={{
+ padding: '0.625rem 1rem',
+ border: 'none',
+ borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
+ background: 'transparent',
+ color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
+ fontWeight: activeTab === tab.id ? 600 : 400,
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ marginBottom: '-2px',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {tab.icon}
+ {tab.label}
+ {tab.readOnly && (
+
+ ({t('read-only')})
+
+ )}
+
+ ))}
+
+
+
+
+ );
+};
+
+export default TrusteeDataTablesView;
diff --git a/src/pages/views/trustee/TrusteeExpenseImportView.tsx b/src/pages/views/trustee/TrusteeExpenseImportView.tsx
index 12804ce..042d2b1 100644
--- a/src/pages/views/trustee/TrusteeExpenseImportView.tsx
+++ b/src/pages/views/trustee/TrusteeExpenseImportView.tsx
@@ -131,7 +131,11 @@ function _extractWorkflowConfig(workflow: any): { connectionReference: string; s
};
}
-export const TrusteeExpenseImportView: React.FC = () => {
+interface TrusteeExpenseImportViewProps {
+ embedded?: boolean;
+}
+
+export const TrusteeExpenseImportView: React.FC = ({ embedded = false }) => {
const { t } = useLanguage();
const { instanceId, mandateId } = useCurrentInstance();
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
@@ -464,10 +468,9 @@ export const TrusteeExpenseImportView: React.FC = () => {
}
};
- return (
-
-
-
{t('Einrichtung des Ausgabenimports')}
+ const content = (
+ <>
+ {!embedded &&
{t('Einrichtung des Ausgabenimports')} }
{t('Verbinden Sie Ihr Microsoft-Konto und wählen Sie einen SharePoint-Ordner mit Ausgaben-PDFs. Das System extrahiert automatisch täglich die Ausgabendaten und speichert sie als Positionen.')}
{
)}
-
+ >
+ );
+
+ if (embedded) {
+ return <>{content}>;
+ }
+
+ return (
+
);
diff --git a/src/pages/views/trustee/TrusteeImportProcessView.tsx b/src/pages/views/trustee/TrusteeImportProcessView.tsx
new file mode 100644
index 0000000..3cffa0f
--- /dev/null
+++ b/src/pages/views/trustee/TrusteeImportProcessView.tsx
@@ -0,0 +1,115 @@
+/**
+ * TrusteeImportProcessView
+ *
+ * Tab-based wrapper for the "Import & Verarbeitung" page. Consolidates the
+ * three import-related entry points under a single navigation item:
+ * - receipts : Belege verarbeiten (SharePoint -> Buchhaltung pipeline)
+ * - upload : Beleg hochladen (scan/manual upload)
+ * - sync : Daten einlesen (redirects to accounting settings,
+ * tab 'import-data' for the actual sync UI)
+ *
+ * The tab is controlled via the URL query parameter `?tab=...`, so
+ * QuickActions and deep links stay stable.
+ */
+
+import React, { useCallback, useEffect } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import styles from './TrusteeViews.module.css';
+import { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
+import { TrusteeScanUploadView } from './TrusteeScanUploadView';
+
+interface TabDef {
+ id: string;
+ icon: string;
+ color: string;
+}
+
+const _TABS: TabDef[] = [
+ { id: 'receipts', icon: '\uD83D\uDCC4', color: '#4CAF50' },
+ { id: 'upload', icon: '\uD83D\uDCF7', color: '#607D8B' },
+ { id: 'sync', icon: '\uD83D\uDD04', color: '#FF9800' },
+];
+
+function _tabLabel(tabId: string, t: (k: string) => string): string {
+ switch (tabId) {
+ case 'receipts': return t('Belege verarbeiten');
+ case 'upload': return t('Beleg hochladen');
+ case 'sync': return t('Daten einlesen');
+ default: return tabId;
+ }
+}
+
+function _tabDescription(tabId: string, t: (k: string) => string): string {
+ switch (tabId) {
+ case 'receipts': return t('Belege aus SharePoint importieren, klassifizieren und verbuchen.');
+ case 'upload': return t('Beleg scannen oder als Datei hochladen.');
+ case 'sync': return t('Buchhaltungsdaten aus dem externen System einlesen.');
+ default: return '';
+ }
+}
+
+export const TrusteeImportProcessView: React.FC = () => {
+ const { t } = useLanguage();
+ const navigate = useNavigate();
+ const { mandateId, featureCode, instanceId } = useParams<{ mandateId: string; featureCode: string; instanceId: string }>();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const activeTab = searchParams.get('tab') || _TABS[0].id;
+ const _setActiveTab = useCallback((tab: string) => {
+ setSearchParams({ tab }, { replace: true });
+ }, [setSearchParams]);
+
+ useEffect(() => {
+ if (activeTab !== 'sync') return;
+ if (!mandateId || !featureCode || !instanceId) return;
+ const target = `/mandates/${mandateId}/${featureCode}/${instanceId}/settings?tab=import-data`;
+ navigate(target, { replace: true });
+ }, [activeTab, mandateId, featureCode, instanceId, navigate]);
+
+ return (
+
+
+
{t('Import & Verarbeitung')}
+
+
+ {_TABS.map((tab) => (
+ _setActiveTab(tab.id)}
+ style={{
+ padding: '0.625rem 1rem',
+ border: 'none',
+ borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
+ background: 'transparent',
+ color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
+ fontWeight: activeTab === tab.id ? 600 : 400,
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ marginBottom: '-2px',
+ }}
+ >
+ {tab.icon}
+ {_tabLabel(tab.id, t)}
+
+ ))}
+
+
+
+ {_tabDescription(activeTab, t)}
+
+
+ {activeTab === 'receipts' &&
}
+ {activeTab === 'upload' &&
}
+ {activeTab === 'sync' && (
+
+
{t('Weiterleitung zu den Buchhaltungs-Einstellungen…')}
+
+ )}
+
+
+ );
+};
+
+export default TrusteeImportProcessView;
diff --git a/src/pages/views/trustee/TrusteeScanUploadView.tsx b/src/pages/views/trustee/TrusteeScanUploadView.tsx
index 6147c2b..4fe2dfb 100644
--- a/src/pages/views/trustee/TrusteeScanUploadView.tsx
+++ b/src/pages/views/trustee/TrusteeScanUploadView.tsx
@@ -38,7 +38,11 @@ const _parseErrorDetail = (detail: unknown): string => {
return String(detail);
};
-export const TrusteeScanUploadView: React.FC = () => {
+interface TrusteeScanUploadViewProps {
+ embedded?: boolean;
+}
+
+export const TrusteeScanUploadView: React.FC = ({ embedded = false }) => {
const { t } = useLanguage();
const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast();
@@ -262,12 +266,11 @@ export const TrusteeScanUploadView: React.FC = () => {
);
}
- return (
-
-
-
{t('Scan-Upload')}
+ const content = (
+ <>
+ {!embedded &&
{t('Scan-Upload')} }
- Upload PDF or JPG documents (receipts, invoices). Then start the pipeline: extract data → create positions → sync to accounting.
+ {t('Laden Sie PDF- oder JPG-Dokumente (Belege, Rechnungen) hoch. Starten Sie dann die Pipeline: Daten extrahieren → Positionen erstellen → in Buchhaltung synchronisieren.')}
{error &&
{error}
}
{pipelineState !== 'idle' && (
@@ -369,6 +372,17 @@ export const TrusteeScanUploadView: React.FC = () => {
>
)}
+ >
+ );
+
+ if (embedded) {
+ return <>{content}>;
+ }
+
+ return (
+
);
diff --git a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
new file mode 100644
index 0000000..04735f5
--- /dev/null
+++ b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
@@ -0,0 +1,309 @@
+/**
+ * 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
= ({
+ 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 = 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(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,
+ }));
+ }, [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 (
+
+
+
⚠️
+
+ {t('Fehler beim Laden: {detail}', { detail: String(error) })}
+
+
+ {t('Erneut versuchen')}
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ {t('Aktualisieren')}
+
+
+
+
+
+
+
+ {editingRow && canUpdate && (
+
+
+
+
+ {entityLabel
+ ? t('{label} bearbeiten', { label: entityLabel })
+ : t('Bearbeiten')}
+
+ setEditingRow(null)}
+ >
+ ✕
+
+
+
+ {formAttributes.length === 0 ? (
+
+
+
{t('Lade Formular')}
+
+ ) : (
+
setEditingRow(null)}
+ submitButtonText={t('Speichern')}
+ cancelButtonText={t('Abbrechen')}
+ instanceId={instanceId || undefined}
+ />
+ )}
+
+
+
+ )}
+
+ );
+};
+
+export default TrusteeDataTab;
diff --git a/src/pages/views/trustee/index.ts b/src/pages/views/trustee/index.ts
index a3d5ca6..625ce78 100644
--- a/src/pages/views/trustee/index.ts
+++ b/src/pages/views/trustee/index.ts
@@ -8,6 +8,8 @@ export { TrusteePositionsView } from './TrusteePositionsView';
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
+export { TrusteeImportProcessView } from './TrusteeImportProcessView';
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
export { TrusteeAnalyseView } from './TrusteeAnalyseView';
export { TrusteeAbschlussView } from './TrusteeAbschlussView';
+export { TrusteeDataTablesView } from './TrusteeDataTablesView';
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx
index de47569..6444c9a 100644
--- a/src/pages/views/workspace/WorkspaceInput.tsx
+++ b/src/pages/views/workspace/WorkspaceInput.tsx
@@ -8,6 +8,7 @@ import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
+import api from '../../../api';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
import { useLanguage } from '../../../providers/language/LanguageContext';
@@ -50,9 +51,22 @@ interface WorkspaceInputProps {
onPasteAsFile?: (file: File) => void;
draftAppend?: string;
onDraftAppendConsumed?: () => void;
+ /**
+ * Per-chat attachment persistence. When the parent loads a workflow, it
+ * passes the IDs the backend has stored for that chat plus a nonce that
+ * increments on every load. The chip-bar is then rehydrated, dropping
+ * any IDs that no longer resolve against the available sources.
+ *
+ * `workflowId` is needed so that "x" detachments can be persisted via a
+ * PATCH call without waiting for the next sendMessage round-trip.
+ */
+ workflowId?: string | null;
+ loadedAttachedDataSourceIds?: string[];
+ loadedAttachedFeatureDataSourceIds?: string[];
+ loadedNonce?: number;
}
-export const WorkspaceInput: React.FC = ({ instanceId: _instanceId,
+export const WorkspaceInput: React.FC = ({ instanceId,
onSend,
isProcessing,
onStop,
@@ -76,6 +90,10 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins
onPasteAsFile,
draftAppend,
onDraftAppendConsumed,
+ workflowId,
+ loadedAttachedDataSourceIds,
+ loadedAttachedFeatureDataSourceIds,
+ loadedNonce,
}) => {
const { t } = useLanguage();
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
@@ -118,6 +136,50 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins
}
}, [pendingAttachFdsId, onPendingAttachFdsConsumed]);
+ // Rehydrate the chip-bar whenever the parent re-loads a chat (loadedNonce
+ // bumps on every loadWorkflow call). We trust the loaded IDs initially;
+ // a separate effect below drops IDs that don't resolve once the source
+ // lists have arrived from the backend.
+ useEffect(() => {
+ if (loadedNonce === undefined) return;
+ setAttachedFileIds([]);
+ setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []);
+ setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []);
+ }, [loadedNonce]);
+
+ // Drop persisted attachment IDs that no longer resolve to an existing
+ // source (e.g. the DataSource was deleted while the chat was closed).
+ // We only run this once the lists are populated to avoid wiping chips
+ // before the lists have loaded.
+ useEffect(() => {
+ if (dataSources.length === 0 && attachedDataSourceIds.length === 0) return;
+ const validIds = new Set(dataSources.map(d => d.id));
+ setAttachedDataSourceIds(prev => {
+ const filtered = prev.filter(id => validIds.has(id));
+ return filtered.length === prev.length ? prev : filtered;
+ });
+ }, [dataSources, attachedDataSourceIds.length]);
+
+ useEffect(() => {
+ if (featureDataSources.length === 0 && attachedFeatureDataSourceIds.length === 0) return;
+ const validIds = new Set(featureDataSources.map(d => d.id));
+ setAttachedFeatureDataSourceIds(prev => {
+ const filtered = prev.filter(id => validIds.has(id));
+ return filtered.length === prev.length ? prev : filtered;
+ });
+ }, [featureDataSources, attachedFeatureDataSourceIds.length]);
+
+ // Persist a changed attachment list to the backend so the next chat
+ // reload reflects the current state. We debounce slightly by sending on
+ // the next animation frame to coalesce rapid clicks.
+ const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => {
+ if (!instanceId || !workflowId) return;
+ api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, {
+ dataSourceIds: dsIds,
+ featureDataSourceIds: fdsIds,
+ }).catch(err => console.warn('Failed to persist chat attachments:', err));
+ }, [instanceId, workflowId]);
+
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');
@@ -210,14 +272,20 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins
}, []);
const _removeAttachedDataSource = useCallback((dsId: string) => {
- setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
- }, []);
+ setAttachedDataSourceIds(prev => {
+ const next = prev.filter(id => id !== dsId);
+ _persistAttachments(next, attachedFeatureDataSourceIds);
+ return next;
+ });
+ }, [_persistAttachments, attachedFeatureDataSourceIds]);
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
- setAttachedFeatureDataSourceIds(prev =>
- prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
- );
- }, []);
+ setAttachedFeatureDataSourceIds(prev => {
+ const next = prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId];
+ _persistAttachments(attachedDataSourceIds, next);
+ return next;
+ });
+ }, [_persistAttachments, attachedDataSourceIds]);
const _buildPromptFromRefs = useCallback(() => {
const parts = [
diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx
index d973a82..b94d96d 100644
--- a/src/pages/views/workspace/WorkspacePage.tsx
+++ b/src/pages/views/workspace/WorkspacePage.tsx
@@ -539,6 +539,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance
onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')}
+ workflowId={workspace.workflowId}
+ loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds}
+ loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds}
+ loadedNonce={workspace.loadedNonce}
/>
diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts
index 21884a8..f79fd54 100644
--- a/src/pages/views/workspace/useWorkspace.ts
+++ b/src/pages/views/workspace/useWorkspace.ts
@@ -116,6 +116,19 @@ interface UseWorkspaceReturn {
refreshFolders: () => void;
refreshDataSources: () => void;
dataSourceAccesses: DataSourceAccessEvent[];
+ /**
+ * Hydrated chip-bar state for the WorkspaceInput. Set by ``loadWorkflow``
+ * to whatever the backend persisted for the chat (per-chat attachment
+ * persistence). Sources that no longer exist are filtered out by the
+ * WorkspaceInput before they're rendered.
+ *
+ * The `loadedNonce` increments on every load so the WorkspaceInput can
+ * tell apart "same workflow, no change" from "user re-loaded the same
+ * chat" and re-hydrate accordingly.
+ */
+ loadedAttachedDataSourceIds: string[];
+ loadedAttachedFeatureDataSourceIds: string[];
+ loadedNonce: number;
}
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
@@ -131,6 +144,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
const [workflowId, setWorkflowId] = useState(null);
const [workflowVersion, setWorkflowVersion] = useState(0);
const [dataSourceAccesses, setDataSourceAccesses] = useState([]);
+ const [loadedAttachedDataSourceIds, setLoadedAttachedDataSourceIds] = useState([]);
+ const [loadedAttachedFeatureDataSourceIds, setLoadedAttachedFeatureDataSourceIds] = useState([]);
+ const [loadedNonce, setLoadedNonce] = useState(0);
const cleanupRef = useRef<(() => void) | null>(null);
const refreshFiles = useCallback(() => {
@@ -177,6 +193,8 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
setPendingEdits([]);
setAgentProgress(null);
setDataSourceAccesses([]);
+ setLoadedAttachedDataSourceIds([]);
+ setLoadedAttachedFeatureDataSourceIds([]);
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
.then(res => {
@@ -184,6 +202,15 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
.map((m: any) => _mapLoadedWorkspaceMessage(m, wfId))
.sort(_compareWorkspaceMessages);
setMessages(msgs);
+ const dsIds: string[] = Array.isArray(res.data.attachedDataSourceIds)
+ ? res.data.attachedDataSourceIds.map((x: unknown) => String(x))
+ : [];
+ const fdsIds: string[] = Array.isArray(res.data.attachedFeatureDataSourceIds)
+ ? res.data.attachedFeatureDataSourceIds.map((x: unknown) => String(x))
+ : [];
+ setLoadedAttachedDataSourceIds(dsIds);
+ setLoadedAttachedFeatureDataSourceIds(fdsIds);
+ setLoadedNonce(n => n + 1);
})
.catch(() => {});
}, [instanceId]);
@@ -195,6 +222,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
setPendingEdits([]);
setAgentProgress(null);
setDataSourceAccesses([]);
+ setLoadedAttachedDataSourceIds([]);
+ setLoadedAttachedFeatureDataSourceIds([]);
+ setLoadedNonce(n => n + 1);
}, []);
const sendMessage = useCallback(
@@ -496,6 +526,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
refreshFolders,
refreshDataSources,
dataSourceAccesses,
+ loadedAttachedDataSourceIds,
+ loadedAttachedFeatureDataSourceIds,
+ loadedNonce,
};
}
diff --git a/src/types/mandate.ts b/src/types/mandate.ts
index 10de5dd..fa6ec17 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -75,7 +75,8 @@ export interface FeatureInstance {
id: string; // UUID der Instanz
featureCode: string; // "trustee", "chatbot", "chatworkflow", etc.
mandateId: string; // Zugehöriger Mandant
- mandateName: string; // Für Anzeige
+ mandateName: string; // Kurzzeichen / Slug des Mandanten (audit-stable)
+ mandateLabel?: string; // Voller Name des Mandanten (UI-Anzeige) — optional fuer Backwards-Compat
instanceLabel: string; // z.B. "PamoCreate AG"
userRoles: string[]; // Rollen des Users in dieser Instanz (kann mehrere haben)
permissions: InstancePermissions;
@@ -205,11 +206,9 @@ export const FEATURE_REGISTRY: Record = {
icon: 'briefcase',
views: [
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
- { code: 'positions', label: 'Positionen', path: 'positions' },
- { code: 'documents', label: 'Dokumente', path: 'documents' },
+ { code: 'data-tables', label: 'Daten-Tabellen', path: 'data-tables' },
{ code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' },
- { code: 'expense-import', label: 'Spesen Import', path: 'expense-import' },
- { code: 'scan-upload', label: 'Scannen / Hochladen', path: 'scan-upload' },
+ { code: 'import-process', label: 'Import & Verarbeitung', path: 'import-process' },
{ code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
{ code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' },
]
diff --git a/src/utils/mandateBillingFormMerge.ts b/src/utils/mandateBillingFormMerge.ts
index 4cc916f..1c9b9b3 100644
--- a/src/utils/mandateBillingFormMerge.ts
+++ b/src/utils/mandateBillingFormMerge.ts
@@ -84,7 +84,29 @@ export function mergeBillingIntoMandateFormData(
};
}
-/** Split form submit payload into mandate PUT body and billing POST body. */
+/** Mandate fields that the AdminMandates form is allowed to update. */
+const _MANDATE_INVOICE_FIELDS = [
+ 'invoiceCompanyName',
+ 'invoiceContactName',
+ 'invoiceEmail',
+ 'invoiceLine1',
+ 'invoiceLine2',
+ 'invoicePostalCode',
+ 'invoiceCity',
+ 'invoiceState',
+ 'invoiceCountry',
+ 'invoiceVatNumber',
+] as const;
+
+/**
+ * Split form submit payload into mandate PUT body and billing POST body.
+ *
+ * Only fields that the user can actually edit are forwarded. Audit-only /
+ * read-only fields (id, deletedAt, isSystem, ...) are intentionally dropped.
+ * The structured ``invoice*`` address fields are round-tripped here so the
+ * address entered in the form is persisted on Mandate; empty strings are
+ * normalized to ``null`` so the backend stores nothing instead of "".
+ */
export function splitMandateAndBillingFromForm(
formData: Record
): { mandatePayload: Record; billingUpdate: BillingSettingsUpdate } {
@@ -92,6 +114,18 @@ export function splitMandateAndBillingFromForm(
if ('name' in formData) mandatePayload.name = formData.name;
if ('label' in formData) mandatePayload.label = formData.label;
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
+ for (const fieldName of _MANDATE_INVOICE_FIELDS) {
+ if (!(fieldName in formData)) continue;
+ const raw = formData[fieldName];
+ if (raw === null || raw === undefined) {
+ mandatePayload[fieldName] = null;
+ } else if (typeof raw === 'string') {
+ const trimmed = raw.trim();
+ mandatePayload[fieldName] = trimmed.length === 0 ? null : trimmed;
+ } else {
+ mandatePayload[fieldName] = raw;
+ }
+ }
const billingUpdate: BillingSettingsUpdate = {};
if (