(null);
+ const isPollingRef = useRef(false);
const handleFileUpload = fileContext?.handleFileUpload;
const uploadingFile = fileContext?.uploadingFile ?? false;
@@ -142,6 +152,145 @@ export const TrusteeScanUploadView: React.FC = () => {
[instanceId]
);
+ const stopPolling = useCallback(() => {
+ if (pollTimerRef.current !== null) {
+ window.clearInterval(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ isPollingRef.current = false;
+ }, []);
+
+ const pollWorkflowStatus = useCallback(
+ async (workflowId: string) => {
+ if (!instanceId || !workflowId || isPollingRef.current) return;
+ isPollingRef.current = true;
+ try {
+ const chatDataRes = await api.get(
+ `/api/chatplayground/${instanceId}/${workflowId}/chatData`,
+ {
+ params: latestTimestampRef.current
+ ? { afterTimestamp: latestTimestampRef.current }
+ : undefined,
+ }
+ );
+
+ const chatItems = Array.isArray(chatDataRes?.data?.items)
+ ? chatDataRes.data.items
+ : [];
+
+ const latestCreatedAt = chatItems.reduce((acc: number, item: any) => {
+ const createdAt = Number(item?.createdAt || 0);
+ return createdAt > acc ? createdAt : acc;
+ }, latestTimestampRef.current || 0);
+ if (latestCreatedAt > 0) {
+ latestTimestampRef.current = latestCreatedAt;
+ }
+
+ const logMessages = chatItems
+ .filter((item: any) => item?.type === 'log')
+ .map((item: any) =>
+ (item?.item?.message || item?.item?.status || '').toString().trim()
+ )
+ .filter((msg: string) => msg.length > 0);
+ const latestLog = logMessages.length
+ ? logMessages[logMessages.length - 1]
+ : '';
+ if (latestLog) {
+ setPipelineDetail(latestLog);
+ }
+
+ const statItems = chatItems.filter((item: any) => item?.type === 'stat');
+ const latestStat =
+ statItems.length > 0 ? statItems[statItems.length - 1]?.item : null;
+ const rawStatus = (
+ latestStat?.status || 'running'
+ ).toString().toLowerCase();
+
+ const messageItems = chatItems.filter(
+ (item: any) => item?.type === 'message'
+ );
+ const completionMessage = messageItems.find(
+ (item: any) =>
+ (item?.item?.message || '').toString().toLowerCase().startsWith('completed:')
+ );
+
+ const isCompleted =
+ rawStatus === 'completed' ||
+ rawStatus === 'stopped' ||
+ !!completionMessage;
+
+ const totalLogs = logMessages.length;
+ const totalMessages = messageItems.length;
+ setPipelineSummary(
+ `Workflow ${workflowId.slice(0, 8)} — ${totalMessages} message(s), ${totalLogs} log(s)`
+ );
+ setLastPollAt(Date.now());
+
+ if (isCompleted) {
+ setPipelineState('completed');
+ stopPolling();
+ showSuccess(
+ 'Pipeline completed',
+ 'Extraction and processing workflow finished successfully.'
+ );
+ return;
+ }
+ if (rawStatus === 'error') {
+ setPipelineState('error');
+ stopPolling();
+ if (latestLog) {
+ setError(latestLog);
+ }
+ showError(
+ 'Pipeline error',
+ latestLog || 'Workflow ended with status "error".'
+ );
+ 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);
+ setPipelineSummary(`Workflow status polling failed: ${msg}`);
+ showError('Polling error', msg);
+ stopPolling();
+ } finally {
+ isPollingRef.current = false;
+ }
+ },
+ [instanceId, showError, showSuccess, stopPolling]
+ );
+
+ useEffect(() => {
+ if (!instanceId || !pipelineWorkflowId || (pipelineState !== 'running' && pipelineState !== 'starting')) {
+ return;
+ }
+
+ void pollWorkflowStatus(pipelineWorkflowId);
+ pollTimerRef.current = window.setInterval(() => {
+ void pollWorkflowStatus(pipelineWorkflowId);
+ }, 3000);
+
+ return () => {
+ stopPolling();
+ };
+ }, [instanceId, pipelineWorkflowId, pipelineState, pollWorkflowStatus, 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.');
@@ -149,14 +298,30 @@ export const TrusteeScanUploadView: React.FC = () => {
}
setIsStarting(true);
setError(null);
+ setPipelineState('starting');
+ setPipelineSummary('Starting pipeline workflow...');
+ setPipelineDetail('');
+ latestTimestampRef.current = null;
try {
const fileIds = uploadedFiles.map((f) => f.fileId);
const template = buildTemplate(fileIds);
const prompt = `\n${JSON.stringify(template)}\n`;
- await api.post(`/api/chatplayground/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
+ const response = await api.post(
+ `/api/chatplayground/${instanceId}/start`,
+ { prompt },
+ { params: { workflowMode: 'Automation' } }
+ );
+ const workflowId = response?.data?.id || response?.data?.workflowId || null;
+ if (!workflowId) {
+ throw new Error('Workflow started but no workflow ID was returned by backend.');
+ }
+ setPipelineWorkflowId(workflowId);
+ setPipelineState('running');
+ setPipelineSummary(`Workflow ${workflowId.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('Error', msg);
} finally {
@@ -180,6 +345,18 @@ export const TrusteeScanUploadView: React.FC = () => {
Upload PDF or JPG documents (receipts, invoices). Then start the pipeline: extract data → create positions → sync to accounting.
{error && {error}
}
+ {pipelineState !== 'idle' && (
+
+
Pipeline status: {pipelineState}
+ {pipelineSummary &&
{pipelineSummary}
}
+ {pipelineDetail &&
Latest log: {pipelineDetail}
}
+ {lastPollAt && (
+
+ Last update: {new Date(lastPollAt).toLocaleTimeString()}
+
+ )}
+
+ )}
{/* Drop zone + file input */}
{
>
)}
diff --git a/src/utils/formatAmount.ts b/src/utils/formatAmount.ts
new file mode 100644
index 0000000..05a8a39
--- /dev/null
+++ b/src/utils/formatAmount.ts
@@ -0,0 +1,31 @@
+/**
+ * Swiss accounting number format: #'##0.00
+ * Apostrophe as thousands separator, dot as decimal, always 2 decimals.
+ *
+ * Examples: 1'234.56 | -50.00 | 0.00 | 12'345'678.90
+ *
+ * @param dashOnZero If true, return "—" when the value is 0.
+ */
+export const formatAmount = (value: unknown, dashOnZero = false): string => {
+ const num = typeof value === 'number' ? value : parseFloat(String(value));
+ if (isNaN(num)) return '—';
+ if (dashOnZero && num === 0) return '—';
+
+ const isNegative = num < 0;
+ const [intPart, decPart] = Math.abs(num).toFixed(2).split('.');
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
+
+ return `${isNegative ? '-' : ''}${grouped}.${decPart}`;
+};
+
+/**
+ * Format a percentage value with 2 decimals (no thousands separator).
+ * Returns "—" when the value is 0, null, undefined or NaN.
+ *
+ * Examples: 7.70% | 19.00% | — (for 0)
+ */
+export const formatPercent = (value: unknown): string => {
+ const num = typeof value === 'number' ? value : parseFloat(String(value));
+ if (isNaN(num) || num === 0) return '—';
+ return `${num.toFixed(2)}%`;
+};