frontend_nyla/src/pages/views/trustee/TrusteeScanUploadView.tsx
2026-04-21 00:50:42 +02:00

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;