fix pipeline polling, add Swiss amount formatting, right-align numbers in tables
Made-with: Cursor
This commit is contained in:
parent
9865a32e99
commit
543b94523a
4 changed files with 246 additions and 17 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
31
src/utils/formatAmount.ts
Normal 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)}%`;
|
||||
};
|
||||
Loading…
Reference in a new issue