/** * TrusteePositionsView * * Positions-Verwaltung für eine Trustee-Instanz. * Verwendet FormGeneratorTable für konsistentes UI. * * NOTE: Mounted only as a tab inside `TrusteeDataTablesView` (Tab `positions` * unter `/mandates/{m}/trustee/{i}/data-tables?tab=positions`). Es gibt keine * eigenständige Top-Level-Route mehr (`/trustee/{i}/positions` wurde entfernt * -- siehe `wiki/c-work/4-done/2026-04-trustee-cleanup-positions-documents.md`). * Direkt-Import durch `TrusteeDataTablesView`; kein Re-Export über * `views/trustee/index.ts`. */ import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useApiRequest } from '../../../hooks/useApi'; import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; import { FaSync, FaDownload } from 'react-icons/fa'; import { useToast } from '../../../contexts/ToastContext'; import api from '../../../api'; import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi'; import { formatAmount, formatPercent } from '../../../utils/formatAmount'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; export const TrusteePositionsView: React.FC = () => { const { t } = useLanguage(); const instanceId = useInstanceId(); const { request } = useApiRequest(); const { showError, showSuccess } = useToast(); const [downloadingDocIds, setDownloadingDocIds] = useState>(new Set()); const [syncStatusItems, setSyncStatusItems] = useState([]); const [syncingPositionIds, setSyncingPositionIds] = useState>(new Set()); // Entity hook const { items: positions, attributes, permissions, pagination, loading, error, refetch, fetchById, updateOptimistically, removeOptimistically, } = useTrusteePositions(); // Operations hook const { handleDelete, handleCreate, handleUpdate, deletingItems, } = useTrusteePositionOperations(); // Modal state const [editingPosition, setEditingPosition] = useState(null); const [isCreateMode, setIsCreateMode] = useState(false); // Initial fetch useEffect(() => { if (instanceId) { refetch(); } }, [instanceId]); // Load sync status for Sync-Status column useEffect(() => { if (!instanceId) return; let cancelled = false; fetchSyncStatus(request, instanceId) .then((data) => { if (!cancelled && data?.items) setSyncStatusItems(data.items); }) .catch(() => {}); return () => { cancelled = true; }; }, [instanceId, request]); const _reloadSyncStatus = useCallback(() => { if (!instanceId) return; fetchSyncStatus(request, instanceId) .then((data) => data?.items && setSyncStatusItems(data.items)) .catch(() => {}); }, [instanceId, request]); const handleBatchSyncToAccounting = useCallback( async (rows: TrusteePosition[]) => { if (!instanceId || rows.length === 0) return; const ids = new Set(rows.map((r) => r.id)); setSyncingPositionIds(ids); try { const res = await syncPositionsToAccounting(request, instanceId, rows.map((r) => r.id)); if (res.errors === 0) { showSuccess('Sync', `${res.success} Position(en) erfolgreich synchronisiert.`); } else if (res.success > 0) { showError('Sync teilweise fehlgeschlagen', `${res.success} OK, ${res.errors} Fehler.`); } else { const firstError = res.results?.find((r: any) => !r.success); showError('Sync fehlgeschlagen', firstError?.errorMessage || `${res.errors} Fehler.`); } refetch(); _reloadSyncStatus(); } catch (err: any) { showError('Sync fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler.'); } finally { setSyncingPositionIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.delete(id)); return next; }); } }, [instanceId, request, refetch, _reloadSyncStatus, showSuccess, showError] ); const handleSingleSyncToAccounting = useCallback( async (row: TrusteePosition) => { await handleBatchSyncToAccounting([row]); }, [handleBatchSyncToAccounting] ); // Document download: same as TrusteeDocumentsView – first load document metadata, then /data blob with correct MIME type and filename const handleDownloadDocument = useCallback( async (documentId: string) => { if (!instanceId) return; setDownloadingDocIds(prev => new Set(prev).add(documentId)); try { const docRes = await api.get(`/api/trustee/${instanceId}/documents/${documentId}`); const doc = docRes.data; if (!doc) { showError('Fehler', 'Dokument nicht gefunden.'); return; } const response = await api.get( `/api/trustee/${instanceId}/documents/${documentId}/data`, { responseType: 'blob' } ); const blob = new Blob([response.data], { type: doc.documentMimeType }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = doc.documentName || 'document'; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (err) { console.error('Download error:', err); showError(t('Fehler'), t('Fehler beim Herunterladen des Dokuments.')); } finally { setDownloadingDocIds(prev => { const next = new Set(prev); next.delete(documentId); return next; }); } }, [instanceId, showError] ); // Hidden columns (not shown in table view, but available in edit form). documentId hidden – use Belege column instead. const hiddenColumns = ['desc', 'documentId', 'featureInstanceId', 'mandateId', 'taxCode', 'costCenter']; // Belege column: icon-only download (Beleg, optional later: Bank-Referenz). Max 0, 1 or 2 docs per position. const belegeColumn: ColumnConfig = useMemo(() => ({ key: '_documentRefs', label: t('Belege'), sortable: false, filterable: false, searchable: false, width: 56, minWidth: 48, maxWidth: 80, formatter: (_value: unknown, row: TrusteePosition) => { const docIds: string[] = [row.documentId, row.bankDocumentId].filter(Boolean) as string[]; if (docIds.length === 0) return ; const labels = ['Beleg', 'Bank-Referenz']; return ( {docIds.map((id, i) => ( ))} ); }, }), [handleDownloadDocument, downloadingDocIds]); // Map positionId -> display sync status: prefer synced over error (successful retry hides old error) const syncByPosition = useMemo(() => { const m = new Map(); for (const s of syncStatusItems) { const cur = m.get(s.positionId); const prefer = !cur || s.syncStatus === 'synced' || (cur.syncStatus !== 'synced' && s.syncStatus === 'error'); if (prefer) m.set(s.positionId, { syncStatus: s.syncStatus, errorMessage: s.errorMessage }); } return m; }, [syncStatusItems]); const syncStatusColumn: ColumnConfig = useMemo( () => ({ key: '_syncStatus', label: t('Synchronisierungsstatus'), sortable: false, filterable: false, searchable: false, width: 160, minWidth: 100, maxWidth: 280, formatter: (_value: unknown, row: TrusteePosition) => { const info = syncByPosition.get(row.id); if (!info) return ; if (info.syncStatus === 'error') return ( Fehler{info.errorMessage ? ': ' + (info.errorMessage.length > 40 ? info.errorMessage.slice(0, 37) + '…' : info.errorMessage) : ''} ); if (info.syncStatus === 'synced') return Synchronisiert; return {info.syncStatus}; }, }), [syncByPosition] ); const positionColumnOrder = [ 'sysCreatedAt', '_documentRefs', '_syncStatus', 'valuta', 'tags', 'company', ]; const amountFields = new Set(['bookingAmount', 'originalAmount']); const vatAmountFields = new Set(['vatAmount']); const percentFields = new Set(['vatPercentage']); // Generate columns from attributes + synthetic columns, then reorder const columns = useMemo(() => { const attrColumns = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) .map(attr => { const col: ColumnConfig = { key: attr.name, label: attr.label || attr.name, type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, }; if (amountFields.has(attr.name)) { col.formatter = (v: unknown) => formatAmount(v); } else if (vatAmountFields.has(attr.name)) { col.formatter = (v: unknown) => formatAmount(v, true); } else if (percentFields.has(attr.name)) { col.formatter = (v: unknown) => formatPercent(v); } return col; }); const allColumns = [...attrColumns, belegeColumn, syncStatusColumn]; const byKey = new Map(allColumns.map(c => [c.key, c])); const ordered: typeof allColumns = []; for (const key of positionColumnOrder) { const col = byKey.get(key); if (col) { ordered.push(col); byKey.delete(key); } } const restKeys = allColumns.map(c => c.key).filter(k => byKey.has(k)); for (const key of restKeys) { const col = byKey.get(key); if (col) ordered.push(col); } return ordered; }, [attributes, belegeColumn, syncStatusColumn]); // Check permissions const canCreate = permissions?.create !== 'n'; const canUpdate = permissions?.update !== 'n'; const canDelete = permissions?.delete !== 'n'; // Handle edit click const handleEditClick = async (pos: TrusteePosition) => { const fullPos = await fetchById(pos.id); if (fullPos) { setEditingPosition(fullPos); setIsCreateMode(false); } }; // Handle create click const handleCreateClick = () => { setEditingPosition(null); setIsCreateMode(true); }; // Handle form submit const handleFormSubmit = async (data: Partial) => { // Auto-calculate VAT if provided const processedData = { ...data }; if (processedData.bookingAmount && processedData.vatPercentage && !processedData.vatAmount) { processedData.vatAmount = processedData.bookingAmount * (processedData.vatPercentage / 100); } if (isCreateMode) { const result = await handleCreate(processedData); if (result.success) { setIsCreateMode(false); refetch(); } } else if (editingPosition) { const result = await handleUpdate(editingPosition.id, processedData); if (result.success) { setEditingPosition(null); refetch(); } } }; // Handle delete (confirmation handled by DeleteActionButton) const handleDeletePos = async (pos: TrusteePosition) => { removeOptimistically(pos.id); const success = await handleDelete(pos.id); if (!success) { refetch(); // Revert on error } }; // Close modal const handleCloseModal = () => { setEditingPosition(null); setIsCreateMode(false); }; // Form attributes (exclude system fields) const formAttributes = useMemo(() => { const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); // Handle inline update const handleInlineUpdate = async (itemId: string, updateData: Partial, row: TrusteePosition) => { updateOptimistically(itemId, updateData); const result = await handleUpdate(itemId, { ...row, ...updateData }); if (!result.success) { refetch(); // Revert on error } }; if (error) { return (
⚠️

{t('Fehler beim Laden der Positionen: {detail}', { detail: String(error) })}

); } return (

{t('Buchungspositionen verwalten')}

{canCreate && ( )}
0, onClick: handleBatchSyncToAccounting, }, ]} actionButtons={[ ...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten'), }] : []), ...(canDelete ? [{ type: 'delete' as const, title: t('Löschen'), loading: (row: TrusteePosition) => deletingItems.has(row.id), }] : []), ]} customActions={[ { id: 'sync-accounting', icon: , title: t('Mit Buchhaltung synchronisieren (einzeln)'), onClick: handleSingleSyncToAccounting, loading: (row: TrusteePosition) => syncingPositionIds.has(row.id), }, ]} onDelete={handleDeletePos} hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically, }} emptyMessage={t('Keine Positionen gefunden')} />
{/* Create/Edit Modal */} {(editingPosition || isCreateMode) && (

{isCreateMode ? t('Neue Position') : t('Position bearbeiten')}

{formAttributes.length === 0 ? (
{t('Lade Formular')}
) : ( )}
)}
); }; export default TrusteePositionsView;