frontend_nyla/src/pages/views/trustee/TrusteePositionsView.tsx
ValueOn AG fc2cce8732 fixes
2026-04-23 23:09:54 +02:00

540 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;