diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
index 33b5d8f..3ba1764 100644
--- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
+++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
@@ -2062,9 +2062,10 @@ export function FormGeneratorTable>({
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 (
|
+ 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)}
|
);
@@ -2174,9 +2175,10 @@ export function FormGeneratorTable>({
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 (
+ 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)}
|
);
diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx
index efc6324..4de9f93 100644
--- a/src/pages/views/trustee/TrusteePositionsView.tsx
+++ b/src/pages/views/trustee/TrusteePositionsView.tsx
@@ -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',
diff --git a/src/pages/views/trustee/TrusteeScanUploadView.tsx b/src/pages/views/trustee/TrusteeScanUploadView.tsx
index 3226e9f..290ff3e 100644
--- a/src/pages/views/trustee/TrusteeScanUploadView.tsx
+++ b/src/pages/views/trustee/TrusteeScanUploadView.tsx
@@ -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(null);
+ const [pipelineState, setPipelineState] = useState('idle');
+ const [pipelineSummary, setPipelineSummary] = useState('');
+ const [pipelineDetail, setPipelineDetail] = useState('');
+ const [pipelineWorkflowId, setPipelineWorkflowId] = useState(null);
+ const [lastPollAt, setLastPollAt] = useState(null);
+ const pollTimerRef = useRef(null);
+ const latestTimestampRef = useRef(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)}%`;
+};