540 lines
19 KiB
TypeScript
540 lines
19 KiB
TypeScript
/**
|
||
* 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<Set<string>>(new Set());
|
||
const [syncStatusItems, setSyncStatusItems] = useState<AccountingSyncStatus[]>([]);
|
||
const [syncingPositionIds, setSyncingPositionIds] = useState<Set<string>>(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<TrusteePosition | null>(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 <span style={{ color: 'var(--text-secondary)' }}>—</span>;
|
||
const labels = ['Beleg', 'Bank-Referenz'];
|
||
return (
|
||
<span style={{ display: 'inline-flex', gap: '4px', alignItems: 'center' }}>
|
||
{docIds.map((id, i) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDownloadDocument(id);
|
||
}}
|
||
disabled={downloadingDocIds.has(id)}
|
||
title={labels[i] ?? `Dokument ${i + 1}`}
|
||
style={{
|
||
padding: '4px',
|
||
minWidth: 28,
|
||
height: 28,
|
||
border: '1px solid var(--border-color, #e5e7eb)',
|
||
borderRadius: 4,
|
||
background: 'var(--bg-secondary, #f9fafb)',
|
||
cursor: downloadingDocIds.has(id) ? 'wait' : 'pointer',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{downloadingDocIds.has(id) ? (
|
||
<span style={{ fontSize: 12 }}>…</span>
|
||
) : (
|
||
<FaDownload size={14} />
|
||
)}
|
||
</button>
|
||
))}
|
||
</span>
|
||
);
|
||
},
|
||
}), [handleDownloadDocument, downloadingDocIds]);
|
||
|
||
// Map positionId -> display sync status: prefer synced over error (successful retry hides old error)
|
||
const syncByPosition = useMemo(() => {
|
||
const m = new Map<string, { syncStatus: string; errorMessage?: string }>();
|
||
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 <span style={{ color: 'var(--text-secondary)' }}>—</span>;
|
||
if (info.syncStatus === 'error')
|
||
return (
|
||
<span
|
||
title={info.errorMessage || ''}
|
||
style={{ color: 'var(--error-color, #dc2626)' }}
|
||
>
|
||
Fehler{info.errorMessage ? ': ' + (info.errorMessage.length > 40 ? info.errorMessage.slice(0, 37) + '…' : info.errorMessage) : ''}
|
||
</span>
|
||
);
|
||
if (info.syncStatus === 'synced')
|
||
return <span style={{ color: 'var(--success-color, #16a34a)' }}>Synchronisiert</span>;
|
||
return <span>{info.syncStatus}</span>;
|
||
},
|
||
}),
|
||
[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<TrusteePosition>) => {
|
||
// 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<TrusteePosition>, row: TrusteePosition) => {
|
||
updateOptimistically(itemId, updateData);
|
||
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
||
if (!result.success) {
|
||
refetch(); // Revert on error
|
||
}
|
||
};
|
||
|
||
if (error) {
|
||
return (
|
||
<div className={styles.adminPage}>
|
||
<div className={styles.errorContainer}>
|
||
<span className={styles.errorIcon}>⚠️</span>
|
||
<p className={styles.errorMessage}>{t('Fehler beim Laden der Positionen: {detail}', { detail: String(error) })}</p>
|
||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||
<FaSync /> {t('Erneut versuchen')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.pageHeader}>
|
||
<div>
|
||
<p className={styles.pageSubtitle}>{t('Buchungspositionen verwalten')}</p>
|
||
</div>
|
||
<div className={styles.headerActions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => refetch()}
|
||
disabled={loading}
|
||
>
|
||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||
</button>
|
||
{canCreate && (
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={handleCreateClick}
|
||
>
|
||
+ {t('Neue Position')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.tableContainer}>
|
||
<FormGeneratorTable
|
||
data={positions}
|
||
columns={columns}
|
||
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/positions` : undefined}
|
||
loading={loading}
|
||
pagination={true}
|
||
pageSize={25}
|
||
searchable={true}
|
||
filterable={true}
|
||
sortable={true}
|
||
selectable={true}
|
||
initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
|
||
batchActions={[
|
||
{
|
||
label: t('Mit Buchhaltung synchronisieren'),
|
||
icon: FaSync,
|
||
loading: syncingPositionIds.size > 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: <FaSync />,
|
||
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')}
|
||
/>
|
||
</div>
|
||
|
||
{/* Create/Edit Modal */}
|
||
{(editingPosition || isCreateMode) && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>
|
||
{isCreateMode ? t('Neue Position') : t('Position bearbeiten')}
|
||
</h2>
|
||
<button
|
||
className={styles.modalClose}
|
||
onClick={handleCloseModal}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalContent}>
|
||
{formAttributes.length === 0 ? (
|
||
<div className={styles.loadingContainer}>
|
||
<div className={styles.spinner} />
|
||
<span>{t('Lade Formular')}</span>
|
||
</div>
|
||
) : (
|
||
<FormGeneratorForm
|
||
attributes={formAttributes}
|
||
data={editingPosition || {
|
||
bookingCurrency: 'CHF',
|
||
originalCurrency: 'CHF',
|
||
bookingAmount: 0,
|
||
originalAmount: 0,
|
||
vatPercentage: 0,
|
||
vatAmount: 0,
|
||
}}
|
||
mode={isCreateMode ? 'create' : 'edit'}
|
||
onSubmit={handleFormSubmit}
|
||
onCancel={handleCloseModal}
|
||
submitButtonText={isCreateMode ? t('Erstellen') : t('Speichern')}
|
||
cancelButtonText={t('Abbrechen')}
|
||
instanceId={instanceId}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TrusteePositionsView;
|