391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
/**
|
|
* 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<TrusteeScanUploadViewProps> = ({ embedded = false }) => {
|
|
const { t } = useLanguage();
|
|
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 [pipelineState, setPipelineState] = useState<PipelineState>('idle');
|
|
const [pipelineSummary, setPipelineSummary] = useState<string>('');
|
|
const [pipelineDetail, setPipelineDetail] = useState<string>('');
|
|
const [pipelineRunId, setPipelineRunId] = useState<string | null>(null);
|
|
const [lastPollAt, setLastPollAt] = useState<number | null>(null);
|
|
const pollTimerRef = useRef<number | null>(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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className={styles.listView}>
|
|
<p>{t('Datei-Upload ist nicht verfügbar')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const content = (
|
|
<>
|
|
{!embedded && <h3 className={styles.sectionTitle}>{t('Scan-Upload')}</h3>}
|
|
<p className={styles.sectionDescription}>
|
|
{t('Laden Sie PDF- oder JPG-Dokumente (Belege, Rechnungen) hoch. Starten Sie dann die Pipeline: Daten extrahieren → Positionen erstellen → in Buchhaltung synchronisieren.')}
|
|
</p>
|
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
|
{pipelineState !== 'idle' && (
|
|
<div className={pipelineState === 'error' ? styles.errorMessage : styles.successMessage}>
|
|
<strong>{t('Pipeline-Status')}</strong> {pipelineState}
|
|
{pipelineSummary && <div style={{ marginTop: '0.25rem' }}>{pipelineSummary}</div>}
|
|
{pipelineDetail && <div style={{ marginTop: '0.25rem' }}>Latest log: {pipelineDetail}</div>}
|
|
{lastPollAt && (
|
|
<div style={{ marginTop: '0.25rem', fontSize: '0.8125rem', opacity: 0.85 }}>
|
|
Last update: {new Date(lastPollAt).toLocaleTimeString()}
|
|
</div>
|
|
)}
|
|
</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={t('Dateien auswählen')}
|
|
/>
|
|
{uploadingFile ? 'Uploading…' : 'Choose files'}
|
|
</label>
|
|
<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={t('Foto aufnehmen')}
|
|
/>
|
|
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 || pipelineState === 'starting' || pipelineState === 'running'}
|
|
>
|
|
{isStarting || pipelineState === 'starting'
|
|
? 'Starting...'
|
|
: pipelineState === 'running'
|
|
? t('Pipeline läuft')
|
|
: t('Pipeline starten')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
if (embedded) {
|
|
return <>{content}</>;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.listView}>
|
|
<div className={styles.expenseImportSection}>
|
|
{content}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TrusteeScanUploadView;
|