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