sync trustee feature with rma
This commit is contained in:
parent
cdea97e2cf
commit
843b481c36
13 changed files with 691 additions and 45 deletions
|
|
@ -150,6 +150,7 @@ function App() {
|
|||
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
|
||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||
|
||||
{/* Chat Playground Feature Views */}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export interface AccountingConfig {
|
|||
isActive?: boolean;
|
||||
lastSyncAt?: number;
|
||||
lastSyncStatus?: string;
|
||||
/** Error message when lastSyncStatus is "error". */
|
||||
lastSyncErrorMessage?: string;
|
||||
/** Masked config for form prefill: secret fields are "***", others have saved values. */
|
||||
configMasked?: Record<string, string>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ export interface FormGeneratorControlsProps {
|
|||
// Delete handlers
|
||||
onDeleteSingle?: () => void;
|
||||
onDeleteMultiple?: () => void;
|
||||
|
||||
// Optional batch actions (e.g. "Sync to Accounting") – shown when items are selected
|
||||
batchActions?: {
|
||||
label: string;
|
||||
onClick: () => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}[];
|
||||
|
||||
// Refresh handler
|
||||
onRefresh?: () => void;
|
||||
|
|
@ -76,6 +84,7 @@ export function FormGeneratorControls({
|
|||
displayData,
|
||||
onDeleteSingle,
|
||||
onDeleteMultiple,
|
||||
batchActions,
|
||||
onRefresh,
|
||||
searchable = true,
|
||||
selectable = true,
|
||||
|
|
@ -127,6 +136,18 @@ export function FormGeneratorControls({
|
|||
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
||||
</Button>
|
||||
)}
|
||||
{batchActions?.map((action, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
onClick={action.onClick}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={action.icon}
|
||||
disabled={action.loading}
|
||||
>
|
||||
{action.loading ? t('common.loading', 'Loading...') : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,13 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
}[];
|
||||
onDelete?: (row: T) => void;
|
||||
onDeleteMultiple?: (rows: T[]) => void;
|
||||
/** Batch actions shown when rows are selected (e.g. Sync to Accounting). Selection is cleared on success. */
|
||||
batchActions?: {
|
||||
label: string;
|
||||
onClick: (rows: T[]) => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}[];
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||
|
|
@ -189,6 +196,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
customActions = [],
|
||||
onDelete,
|
||||
onDeleteMultiple,
|
||||
batchActions = [],
|
||||
onRefresh,
|
||||
className = '',
|
||||
getRowDataAttributes,
|
||||
|
|
@ -1387,6 +1395,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
|
||||
// Format cell value
|
||||
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
||||
// Custom formatter must run even when value is null/undefined (e.g. synthetic columns like _documentRefs)
|
||||
if (column.formatter) {
|
||||
return column.formatter(value, row);
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return '-';
|
||||
}
|
||||
|
|
@ -1495,11 +1508,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return displayValues.join(', ');
|
||||
}
|
||||
|
||||
// Use custom formatter if provided (but only if not an ID/hash field)
|
||||
if (column.formatter) {
|
||||
return column.formatter(value, row);
|
||||
}
|
||||
|
||||
// Check if this is a timestamp field based on name OR explicit type
|
||||
// Do NOT treat arbitrary numbers as timestamps - only if field name suggests it
|
||||
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked|valuta)$/i.test(column.key);
|
||||
|
|
@ -1644,6 +1652,21 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
|
||||
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
|
||||
})()}
|
||||
batchActions={batchActions.length > 0 ? batchActions.map((ba) => ({
|
||||
label: ba.label,
|
||||
icon: ba.icon,
|
||||
loading: ba.loading,
|
||||
onClick: async () => {
|
||||
const rows = Array.from(selectedRows).map((i) => displayData[i]);
|
||||
try {
|
||||
await Promise.resolve(ba.onClick(rows));
|
||||
setSelectedRows(new Set());
|
||||
onRowSelect?.([]);
|
||||
} catch {
|
||||
// Keep selection on error so user can retry
|
||||
}
|
||||
},
|
||||
})) : undefined}
|
||||
onRefresh={onRefresh}
|
||||
searchable={searchable}
|
||||
selectable={selectable}
|
||||
|
|
|
|||
|
|
@ -69,13 +69,16 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.admin.automation-events': <FaClock />,
|
||||
'page.admin.logs': <FaFileAlt />,
|
||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||
'page.admin.mandateWizard': <FaHatWizard />,
|
||||
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
||||
'page.admin.invitationWizard': <FaEnvelopeOpenText />,
|
||||
|
||||
// Feature pages - Trustee
|
||||
'page.feature.trustee.dashboard': <FaChartLine />,
|
||||
'page.feature.trustee.positions': <FaDatabase />,
|
||||
'page.feature.trustee.documents': <FaFileAlt />,
|
||||
'page.feature.trustee.expense-import': <FaFileAlt />,
|
||||
'page.feature.trustee.scan-upload': <FaFileAlt />,
|
||||
'page.feature.trustee.instance-roles': <FaUserShield />,
|
||||
'page.feature.trustee.settings': <FaCog />,
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ interface FileContextType {
|
|||
downloadingFiles: Set<string>;
|
||||
}
|
||||
|
||||
const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||
|
||||
export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: files, loading, error, refetch: refetchFiles, removeFileOptimistically } = useUserFiles();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
|
|||
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 { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
||||
|
||||
// Chatbot Views
|
||||
|
|
@ -97,6 +98,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
positions: TrusteePositionsView,
|
||||
'instance-roles': TrusteeInstanceRolesView,
|
||||
'expense-import': TrusteeExpenseImportView,
|
||||
'scan-upload': TrusteeScanUploadView,
|
||||
settings: TrusteeAccountingSettingsView,
|
||||
},
|
||||
chatworkflow: {
|
||||
|
|
|
|||
|
|
@ -151,14 +151,44 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
</p>
|
||||
|
||||
{existingConfig?.configured && (
|
||||
<div className={styles.successMessage} style={{ marginBottom: '1rem' }}>
|
||||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Connected:</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||
{existingConfig.lastSyncStatus && (
|
||||
<> — Last sync: {existingConfig.lastSyncStatus}</>
|
||||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||
<> · Last sync: {existingConfig.lastSyncStatus}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingConfig?.configured && (existingConfig.lastSyncAt != null || existingConfig.lastSyncStatus != null) && (
|
||||
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||||
<div className={styles.stepContent}>
|
||||
<h4 style={{ marginTop: 0 }}>Sync-Status / Fehlerprotokoll</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{existingConfig.lastSyncAt != null && (
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>Letzter Sync:</strong>{' '}
|
||||
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
||||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||
<> · Status: {existingConfig.lastSyncStatus}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{existingConfig.lastSyncStatus === 'error' && (existingConfig.lastSyncErrorMessage ?? '').trim() !== '' && (
|
||||
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||||
{existingConfig.lastSyncErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
{existingConfig.lastSyncStatus === 'error' && (!existingConfig.lastSyncErrorMessage || existingConfig.lastSyncErrorMessage.trim() === '') && (
|
||||
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||||
Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Select system */}
|
||||
<div className={styles.setupStep}>
|
||||
<div className={styles.stepNumber}>1</div>
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
const [existingAutomation, setExistingAutomation] = useState<ExistingAutomation | null>(null);
|
||||
const [isLoadingAutomation, setIsLoadingAutomation] = useState(true);
|
||||
const [showInfoTooltip, setShowInfoTooltip] = useState(false);
|
||||
const [isRunningNow, setIsRunningNow] = useState(false);
|
||||
|
||||
// Find all active Microsoft connections
|
||||
useEffect(() => {
|
||||
|
|
@ -318,6 +319,41 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildTrusteeTemplate = useCallback((connectionRef: string, folder: string) => ({
|
||||
overview: "Trustee document pipeline: extract, process, sync to accounting",
|
||||
tasks: [{
|
||||
id: "Task01",
|
||||
title: "Trustee expense import",
|
||||
description: "Extract from SharePoint, create positions, sync to accounting",
|
||||
objective: "Run trustee.extractFromFiles, processDocuments, syncToAccounting",
|
||||
actionList: [
|
||||
{
|
||||
execMethod: "trustee",
|
||||
execAction: "extractFromFiles",
|
||||
execParameters: {
|
||||
connectionReference: connectionRef,
|
||||
sharepointFolder: folder,
|
||||
featureInstanceId: instanceId,
|
||||
prompt: DEFAULT_EXTRACTION_PROMPT
|
||||
},
|
||||
execResultLabel: "extract_result"
|
||||
},
|
||||
{
|
||||
execMethod: "trustee",
|
||||
execAction: "processDocuments",
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: "process_result"
|
||||
},
|
||||
{
|
||||
execMethod: "trustee",
|
||||
execAction: "syncToAccounting",
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: "sync_result"
|
||||
}
|
||||
]
|
||||
}]
|
||||
}), [instanceId]);
|
||||
|
||||
const handleSave = async (activate: boolean = true) => {
|
||||
// Validate required fields with user feedback
|
||||
|
|
@ -341,29 +377,11 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
try {
|
||||
const connectionReference = `connection:msft:${msftConnection.externalUsername || msftConnection.accountName || msftConnection.id}`;
|
||||
|
||||
const template = buildTrusteeTemplate(connectionReference, selectedFolder);
|
||||
const automationData = {
|
||||
label: 'Expense Import',
|
||||
schedule: '0 22 * * *', // Daily at 22:00
|
||||
template: JSON.stringify({
|
||||
overview: "Expenses PDF to Trustee Position",
|
||||
tasks: [{
|
||||
id: "Task01",
|
||||
title: "Extract Expenses from SharePoint PDFs",
|
||||
description: "Automatic expense extraction",
|
||||
objective: "Extract expense data from PDF documents",
|
||||
actionList: [{
|
||||
execMethod: "sharepoint",
|
||||
execAction: "getExpensesFromPdf",
|
||||
execParameters: {
|
||||
connectionReference: connectionReference,
|
||||
sharepointFolder: selectedFolder,
|
||||
featureInstanceId: instanceId,
|
||||
prompt: DEFAULT_EXTRACTION_PROMPT
|
||||
},
|
||||
execResultLabel: "expense_extraction_result"
|
||||
}]
|
||||
}]
|
||||
}),
|
||||
template: JSON.stringify(template),
|
||||
placeholders: {
|
||||
connectionName: connectionReference,
|
||||
sharepointFolder: selectedFolder,
|
||||
|
|
@ -414,7 +432,29 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
setIsActivating(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleRunNow = async () => {
|
||||
if (!msftConnection || !selectedFolder || !instanceId) {
|
||||
showError('Missing data', 'Please select connection and folder first.');
|
||||
return;
|
||||
}
|
||||
setIsRunningNow(true);
|
||||
setError(null);
|
||||
try {
|
||||
const connectionRef = getConnectionReference(msftConnection);
|
||||
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
|
||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||
await api.post(`/api/chatplayground/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
|
||||
showSuccess('Started', 'Workflow started. Extract → Process → Sync will run once.');
|
||||
} catch (err: any) {
|
||||
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||
setError(msg);
|
||||
showError('Error', msg);
|
||||
} finally {
|
||||
setIsRunningNow(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!existingAutomation) return;
|
||||
|
||||
|
|
@ -662,6 +702,13 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
>
|
||||
{isActivating ? 'Saving...' : (existingAutomation ? 'Save & Activate' : 'Activate Daily Import')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleRunNow}
|
||||
disabled={isActivating || isRunningNow}
|
||||
>
|
||||
{isRunningNow ? 'Starting...' : 'Jetzt ausführen'}
|
||||
</button>
|
||||
{existingAutomation && existingAutomation.active && (
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,26 @@
|
|||
* Verwendet FormGeneratorTable für konsistentes UI.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaReceipt } from 'react-icons/fa';
|
||||
import { FaSync, FaReceipt, FaDownload } from 'react-icons/fa';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import api from '../../../api';
|
||||
import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi';
|
||||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
export const TrusteePositionsView: React.FC = () => {
|
||||
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,
|
||||
|
|
@ -49,10 +58,211 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Hidden columns (not shown in table view, but available in edit form)
|
||||
const hiddenColumns = ['desc', 'featureInstanceId', 'mandateId', 'taxCode', 'costCenter'];
|
||||
// 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]);
|
||||
|
||||
// Generate columns from attributes + add system columns
|
||||
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('Fehler', '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: '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: 'Sync-Status',
|
||||
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]
|
||||
);
|
||||
|
||||
// Desired column order: Belege (icons), Sync-Status, Erstellt am, Valuta, Tags, Company, then the rest
|
||||
const positionColumnOrder = [
|
||||
'_documentRefs', // Belege (download icons)
|
||||
'_syncStatus', // Sync-Status
|
||||
'_createdAt', // Erstellt am
|
||||
'valuta', // Valuta date
|
||||
'tags',
|
||||
'company',
|
||||
];
|
||||
|
||||
// Generate columns from attributes + synthetic columns, then reorder
|
||||
const columns = useMemo(() => {
|
||||
const attrColumns = (attributes || [])
|
||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||
|
|
@ -67,9 +277,8 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
|
||||
// Add _createdAt system column
|
||||
attrColumns.push({
|
||||
|
||||
const createdAtCol = {
|
||||
key: '_createdAt',
|
||||
label: 'Erstellt am',
|
||||
type: 'timestamp' as any,
|
||||
|
|
@ -79,10 +288,26 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
width: 150,
|
||||
minWidth: 120,
|
||||
maxWidth: 200,
|
||||
});
|
||||
|
||||
return attrColumns;
|
||||
}, [attributes]);
|
||||
};
|
||||
|
||||
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn, createdAtCol];
|
||||
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';
|
||||
|
|
@ -229,7 +454,15 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
selectable={true}
|
||||
batchActions={[
|
||||
{
|
||||
label: 'Buchhaltung synchronisieren',
|
||||
icon: <FaSync />,
|
||||
loading: syncingPositionIds.size > 0,
|
||||
onClick: handleBatchSyncToAccounting,
|
||||
},
|
||||
]}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
|
|
@ -242,6 +475,15 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
loading: (row: TrusteePosition) => deletingItems.has(row.id),
|
||||
}] : []),
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'sync-accounting',
|
||||
icon: <FaSync />,
|
||||
title: 'In Buchhaltung synchronisieren',
|
||||
onClick: handleSingleSyncToAccounting,
|
||||
loading: (row: TrusteePosition) => syncingPositionIds.has(row.id),
|
||||
},
|
||||
]}
|
||||
onDelete={handleDeletePos}
|
||||
hookData={{
|
||||
refetch,
|
||||
|
|
|
|||
272
src/pages/views/trustee/TrusteeScanUploadView.tsx
Normal file
272
src/pages/views/trustee/TrusteeScanUploadView.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* TrusteeScanUploadView (UC1)
|
||||
*
|
||||
* Mobile-friendly scan/upload: photo, drag-and-drop, or file picker.
|
||||
* Uploads files, then starts the trustee pipeline (extract → process → sync) with fileIds.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useContext } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { FileContext } from '../../../contexts/FileContext';
|
||||
import api from '../../../api';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
|
||||
const DEFAULT_EXTRACTION_PROMPT = `Extrahiere Spesendaten aus dem Dokument. Gib Dokumenttyp (INVOICE, EXPENSE_RECEIPT, …) und Datensätze zurück. CSV-Spalten: valuta,company,desc,bookingAmount,bookingCurrency,vatPercentage,vatAmount,debitAccountNumber,creditAccountNumber,tags.`;
|
||||
|
||||
interface UploadedEntry {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
const parseErrorDetail = (detail: unknown): string => {
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return (detail as Array<{ msg?: string }>).map((e) => e.msg || JSON.stringify(e)).join(', ');
|
||||
}
|
||||
if (detail && typeof detail === 'object' && 'msg' in (detail as object)) return (detail as { msg: string }).msg;
|
||||
if (detail && typeof detail === 'object' && 'message' in (detail as object)) return (detail as { message: string }).message;
|
||||
return String(detail);
|
||||
};
|
||||
|
||||
export const TrusteeScanUploadView: React.FC = () => {
|
||||
const { instanceId } = useCurrentInstance();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const fileContext = useContext(FileContext);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedEntry[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFileUpload = fileContext?.handleFileUpload;
|
||||
const uploadingFile = fileContext?.uploadingFile ?? false;
|
||||
|
||||
const addFile = useCallback((fileId: string, fileName: string) => {
|
||||
setUploadedFiles((prev) => {
|
||||
if (prev.some((f) => f.fileId === fileId)) return prev;
|
||||
return [...prev, { fileId, fileName }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setUploadedFiles((prev) => prev.filter((f) => f.fileId !== fileId));
|
||||
}, []);
|
||||
|
||||
const onFiles = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files?.length || !handleFileUpload || !instanceId) return;
|
||||
setError(null);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const name = file.name.toLowerCase();
|
||||
if (!name.endsWith('.pdf') && !name.endsWith('.jpg') && !name.endsWith('.jpeg')) {
|
||||
showError('Invalid file', `${file.name}: only PDF and JPG are supported.`);
|
||||
continue;
|
||||
}
|
||||
const result = await handleFileUpload(file);
|
||||
if (result.success && result.fileData) {
|
||||
const data = result.fileData?.file || result.fileData;
|
||||
const id = data?.id || result.fileData?.id;
|
||||
if (id) addFile(id, file.name);
|
||||
else showError('Upload', 'Could not get file ID from server.');
|
||||
} else {
|
||||
showError('Upload failed', result.error || 'Unknown error');
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFileUpload, instanceId, showError, addFile]
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
onFiles(e.dataTransfer.files);
|
||||
},
|
||||
[onFiles]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const onDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
},
|
||||
[onFiles]
|
||||
);
|
||||
|
||||
const buildTemplate = useCallback(
|
||||
(fileIds: string[]) => ({
|
||||
overview: 'Trustee pipeline from uploaded files',
|
||||
tasks: [
|
||||
{
|
||||
id: 'Task01',
|
||||
title: 'Extract, process, sync',
|
||||
objective: 'Run trustee pipeline on uploaded files',
|
||||
actionList: [
|
||||
{
|
||||
execMethod: 'trustee',
|
||||
execAction: 'extractFromFiles',
|
||||
execParameters: {
|
||||
fileIds,
|
||||
featureInstanceId: instanceId,
|
||||
prompt: DEFAULT_EXTRACTION_PROMPT,
|
||||
},
|
||||
execResultLabel: 'extract_result',
|
||||
},
|
||||
{
|
||||
execMethod: 'trustee',
|
||||
execAction: 'processDocuments',
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: 'process_result',
|
||||
},
|
||||
{
|
||||
execMethod: 'trustee',
|
||||
execAction: 'syncToAccounting',
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: 'sync_result',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[instanceId]
|
||||
);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!instanceId || uploadedFiles.length === 0) {
|
||||
showError('Missing data', 'Please upload at least one PDF or JPG first.');
|
||||
return;
|
||||
}
|
||||
setIsStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fileIds = uploadedFiles.map((f) => f.fileId);
|
||||
const template = buildTemplate(fileIds);
|
||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||
await api.post(`/api/chatplayground/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
|
||||
showSuccess('Started', 'Pipeline started: Extract → Process → Sync. You can follow progress in Workflows.');
|
||||
} catch (err: any) {
|
||||
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||
setError(msg);
|
||||
showError('Error', msg);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}, [instanceId, uploadedFiles, buildTemplate, showSuccess, showError]);
|
||||
|
||||
if (!fileContext) {
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
<p>File upload is not available in this context.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
<div className={styles.expenseImportSection}>
|
||||
<h3 className={styles.sectionTitle}>Scan / Upload</h3>
|
||||
<p className={styles.sectionDescription}>
|
||||
Upload PDF or JPG documents (receipts, invoices). Then start the pipeline: extract data → create positions → sync to accounting.
|
||||
</p>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
|
||||
{/* Drop zone + file input */}
|
||||
<div
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
style={{
|
||||
border: `2px dashed ${isDragging ? 'var(--primary-color, #2563eb)' : 'var(--border-color, #d0d0d0)'}`,
|
||||
borderRadius: 8,
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
background: isDragging ? 'var(--surface-color, #f0f4ff)' : 'var(--bg-secondary, #fafafa)',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: '0 0 1rem 0', fontSize: '0.9375rem' }}>
|
||||
Drag files here or choose below. PDF and JPG only.
|
||||
</p>
|
||||
<label className={styles.primaryButton} style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,application/pdf,image/jpeg"
|
||||
multiple
|
||||
onChange={onInputChange}
|
||||
disabled={uploadingFile}
|
||||
style={{ display: 'none' }}
|
||||
aria-label="Choose files"
|
||||
/>
|
||||
{uploadingFile ? 'Uploading…' : 'Choose files'}
|
||||
</label>
|
||||
{/* Mobile: optional camera capture */}
|
||||
<label className={styles.secondaryButton} style={{ cursor: 'pointer', display: 'inline-block', marginLeft: '0.5rem' }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={onInputChange}
|
||||
disabled={uploadingFile}
|
||||
style={{ display: 'none' }}
|
||||
aria-label="Take photo"
|
||||
/>
|
||||
Take photo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Uploaded list */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<>
|
||||
<p className={styles.sectionDescription}>
|
||||
<strong>{uploadedFiles.length}</strong> file(s) ready. Click Start to run the pipeline.
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 1rem 0' }}>
|
||||
{uploadedFiles.map(({ fileId, fileName }) => (
|
||||
<li
|
||||
key={fileId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.5rem 0',
|
||||
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.875rem' }}>{fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => removeFile(fileId)}
|
||||
style={{ padding: '0.25rem 0.5rem', fontSize: '0.8125rem' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
>
|
||||
{isStarting ? 'Starting…' : 'Start pipeline'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrusteeScanUploadView;
|
||||
|
|
@ -7,4 +7,5 @@ export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
|||
export { TrusteePositionsView } from './TrusteePositionsView';
|
||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
||||
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
||||
|
|
|
|||
|
|
@ -208,7 +208,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
|
||||
{ code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-documents' },
|
||||
{ code: 'expense-import', label: { de: 'Spesen Import', en: 'Expense Import' }, path: 'expense-import' },
|
||||
{ code: 'scan-upload', label: { de: 'Scannen / Hochladen', en: 'Scan / Upload' }, path: 'scan-upload' },
|
||||
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
||||
{ code: 'settings', label: { de: 'Buchhaltungseinstellungen', en: 'Accounting Settings' }, path: 'settings' },
|
||||
]
|
||||
},
|
||||
chatworkflow: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue