fix pipeline polling, add Swiss amount formatting, right-align numbers in tables

Made-with: Cursor
This commit is contained in:
ValueOn AG 2026-03-01 21:54:57 +01:00
parent 9865a32e99
commit 543b94523a
4 changed files with 246 additions and 17 deletions

View file

@ -2062,9 +2062,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const cellValue = row[column.key];
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
const combinedClassName = `${styles.td} ${customClassName}`.trim();
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
return (
<td key={column.key} className={combinedClassName}
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150 }}>
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...(isNumeric ? { textAlign: 'right' } : {}) }}>
{formatCellValue(cellValue, column, row)}
</td>
);
@ -2174,9 +2175,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const cellValue = row[column.key];
const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : '';
const combinedClassName = `${styles.td} ${customClassName}`.trim();
const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer';
return (
<td key={column.key} className={combinedClassName}
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150 }}>
style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...(isNumeric ? { textAlign: 'right' } : {}) }}>
{formatCellValue(cellValue, column, row)}
</td>
);

View file

@ -15,6 +15,7 @@ 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 { formatAmount, formatPercent } from '../../../utils/formatAmount';
import styles from '../../admin/Admin.module.css';
export const TrusteePositionsView: React.FC = () => {
@ -262,21 +263,35 @@ export const TrusteePositionsView: React.FC = () => {
'company',
];
const amountFields = new Set(['bookingAmount', 'originalAmount']);
const vatAmountFields = new Set(['vatAmount']);
const percentFields = new Set(['vatPercentage']);
// Generate columns from attributes + synthetic columns, then reorder
const columns = useMemo(() => {
const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
.map(attr => {
const col: ColumnConfig = {
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
};
if (amountFields.has(attr.name)) {
col.formatter = (v: unknown) => formatAmount(v);
} else if (vatAmountFields.has(attr.name)) {
col.formatter = (v: unknown) => formatAmount(v, true);
} else if (percentFields.has(attr.name)) {
col.formatter = (v: unknown) => formatPercent(v);
}
return col;
});
const createdAtCol = {
key: '_createdAt',

View file

@ -5,7 +5,7 @@
* Uploads files, then starts the trustee pipeline (extract process sync) with fileIds.
*/
import React, { useState, useCallback, useContext } from 'react';
import React, { useState, useCallback, useContext, useEffect, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
import { FileContext } from '../../../contexts/FileContext';
@ -19,6 +19,8 @@ interface UploadedEntry {
fileName: string;
}
type PipelineState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
const parseErrorDetail = (detail: unknown): string => {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
@ -37,6 +39,14 @@ export const TrusteeScanUploadView: React.FC = () => {
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 [pipelineWorkflowId, setPipelineWorkflowId] = useState<string | null>(null);
const [lastPollAt, setLastPollAt] = useState<number | null>(null);
const pollTimerRef = useRef<number | null>(null);
const latestTimestampRef = useRef<number | null>(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 = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
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.
</p>
{error && <div className={styles.errorMessage}>{error}</div>}
{pipelineState !== 'idle' && (
<div className={pipelineState === 'error' ? styles.errorMessage : styles.successMessage}>
<strong>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
@ -258,9 +435,13 @@ export const TrusteeScanUploadView: React.FC = () => {
<button
className={styles.primaryButton}
onClick={handleStart}
disabled={isStarting}
disabled={isStarting || pipelineState === 'starting' || pipelineState === 'running'}
>
{isStarting ? 'Starting…' : 'Start pipeline'}
{isStarting || pipelineState === 'starting'
? 'Starting...'
: pipelineState === 'running'
? 'Pipeline running...'
: 'Start pipeline'}
</button>
</>
)}

31
src/utils/formatAmount.ts Normal file
View file

@ -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)}%`;
};