/** * TrusteeScanUploadView (UC1) * * Mobile-friendly scan/upload: photo, drag-and-drop, or file picker. * Uploads files, then starts the trustee pipeline (extract → process → sync) * via the consolidated graphicalEditor execution engine. * * The /api/workflows/ routes accept any feature instanceId the user has access to; * no separate graphicalEditor instance is needed. */ import React, { useState, useCallback, useContext, useEffect, useRef } from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useToast } from '../../../contexts/ToastContext'; import { FileContext } from '../../../contexts/FileContext'; import api from '../../../api'; import { _buildScanUploadGraph } from './trusteePipelineGraph'; import styles from './TrusteeViews.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; 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; } type PipelineState = 'idle' | 'starting' | 'running' | 'completed' | 'error'; 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); }; interface TrusteeScanUploadViewProps { embedded?: boolean; } export const TrusteeScanUploadView: React.FC = ({ embedded = false }) => { const { t } = useLanguage(); const { instanceId } = useCurrentInstance(); const { showSuccess, showError } = useToast(); const fileContext = useContext(FileContext); const [uploadedFiles, setUploadedFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isStarting, setIsStarting] = useState(false); const [error, setError] = useState(null); const [pipelineState, setPipelineState] = useState('idle'); const [pipelineSummary, setPipelineSummary] = useState(''); const [pipelineDetail, setPipelineDetail] = useState(''); const [pipelineRunId, setPipelineRunId] = useState(null); const [lastPollAt, setLastPollAt] = useState(null); const pollTimerRef = useRef(null); const isPollingRef = useRef(false); 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) => { onFiles(e.target.files); e.target.value = ''; }, [onFiles] ); const stopPolling = useCallback(() => { if (pollTimerRef.current !== null) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } isPollingRef.current = false; }, []); const pollRunStatus = useCallback( async (runId: string) => { if (!instanceId || !runId || isPollingRef.current) return; isPollingRef.current = true; try { const stepsRes = await api.get( `/api/workflows/${instanceId}/runs/${runId}/steps` ); const steps = Array.isArray(stepsRes?.data?.steps) ? stepsRes.data.steps : []; const completedSteps = steps.filter((s: any) => s.status === 'completed'); const failedSteps = steps.filter((s: any) => s.status === 'failed'); const runningSteps = steps.filter((s: any) => s.status === 'running'); const latestStep = steps.length > 0 ? steps[steps.length - 1] : null; setLastPollAt(Date.now()); setPipelineSummary( `Run ${runId.slice(0, 8)} — ${completedSteps.length}/${steps.length} steps completed` ); if (latestStep) { const label = latestStep.nodeType || latestStep.nodeId || ''; const status = latestStep.status || ''; setPipelineDetail(`${label}: ${status}`); } if (failedSteps.length > 0) { const failedStep = failedSteps[failedSteps.length - 1]; const errMsg = failedStep.error || 'Step failed'; setPipelineState('error'); setError(errMsg); stopPolling(); showError('Pipeline error', errMsg); return; } if (runningSteps.length === 0 && completedSteps.length === steps.length && steps.length > 0) { setPipelineState('completed'); stopPolling(); showSuccess('Pipeline completed', 'Extraction and processing workflow finished successfully.'); return; } setPipelineState('running'); } catch (pollErr: any) { if (pollErr?.response?.status === 404) { setPipelineState('running'); return; } const msg = _parseErrorDetail(pollErr.response?.data?.detail) || pollErr.message || 'Polling failed'; setPipelineState('error'); setError(msg); stopPolling(); showError('Polling error', msg); } finally { isPollingRef.current = false; } }, [instanceId, showError, showSuccess, stopPolling] ); useEffect(() => { if (!instanceId || !pipelineRunId || (pipelineState !== 'running' && pipelineState !== 'starting')) { return; } void pollRunStatus(pipelineRunId); pollTimerRef.current = window.setInterval(() => { void pollRunStatus(pipelineRunId); }, 3000); return () => { stopPolling(); }; }, [instanceId, pipelineRunId, pipelineState, pollRunStatus, stopPolling]); useEffect(() => { return () => { stopPolling(); }; }, [stopPolling]); 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); setPipelineState('starting'); setPipelineSummary('Starting pipeline workflow...'); setPipelineDetail(''); try { const fileIds = uploadedFiles.map((f) => f.fileId); const graph = _buildScanUploadGraph(instanceId, fileIds, DEFAULT_EXTRACTION_PROMPT); const response = await api.post( `/api/workflows/${instanceId}/execute`, { graph } ); const runId = response?.data?.runId || null; if (!runId) { const success = response?.data?.success; if (success) { setPipelineState('completed'); setPipelineSummary('Pipeline completed (synchronous execution).'); showSuccess('Completed', 'Pipeline finished: Extract → Process → Sync.'); } else { throw new Error(response?.data?.error || 'Workflow executed but no run ID returned.'); } } else { setPipelineRunId(runId); setPipelineState('running'); setPipelineSummary(`Run ${runId.slice(0, 8)} started. Waiting for progress updates...`); 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'; setPipelineState('error'); setError(msg); showError(t('Fehler'), msg); } finally { setIsStarting(false); } }, [instanceId, uploadedFiles, showSuccess, showError]); if (!fileContext) { return (

{t('Datei-Upload ist nicht verfügbar')}

); } const content = ( <> {!embedded &&

{t('Scan-Upload')}

}

{t('Laden Sie PDF- oder JPG-Dokumente (Belege, Rechnungen) hoch. Starten Sie dann die Pipeline: Daten extrahieren → Positionen erstellen → in Buchhaltung synchronisieren.')}

{error &&
{error}
} {pipelineState !== 'idle' && (
{t('Pipeline-Status')} {pipelineState} {pipelineSummary &&
{pipelineSummary}
} {pipelineDetail &&
Latest log: {pipelineDetail}
} {lastPollAt && (
Last update: {new Date(lastPollAt).toLocaleTimeString()}
)}
)} {/* Drop zone + file input */}

Drag files here or choose below. PDF and JPG only.

{/* Uploaded list */} {uploadedFiles.length > 0 && ( <>

{uploadedFiles.length} file(s) ready. Click Start to run the pipeline.

    {uploadedFiles.map(({ fileId, fileName }) => (
  • {fileName}
  • ))}
)} ); if (embedded) { return <>{content}; } return (
{content}
); }; export default TrusteeScanUploadView;