sync trustee feature with rma

This commit is contained in:
ValueOn AG 2026-02-22 00:07:40 +01:00
parent cdea97e2cf
commit 843b481c36
13 changed files with 691 additions and 45 deletions

View file

@ -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 */}

View file

@ -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>;
}

View file

@ -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>
)}

View file

@ -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}

View file

@ -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 />,

View file

@ -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();

View file

@ -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: {

View file

@ -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 && (
<> &mdash; Last sync: {existingConfig.lastSyncStatus}</>
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
<> &middot; 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 !== '' && (
<> &middot; 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>

View file

@ -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}

View file

@ -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,

View 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;

View file

@ -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';

View file

@ -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: {