sync trustee feature with rma
This commit is contained in:
parent
cdea97e2cf
commit
843b481c36
13 changed files with 691 additions and 45 deletions
|
|
@ -150,6 +150,7 @@ function App() {
|
||||||
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
|
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
|
||||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||||
|
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||||
|
|
||||||
{/* Chat Playground Feature Views */}
|
{/* Chat Playground Feature Views */}
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,8 @@ export interface AccountingConfig {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
lastSyncAt?: number;
|
lastSyncAt?: number;
|
||||||
lastSyncStatus?: string;
|
lastSyncStatus?: string;
|
||||||
|
/** Error message when lastSyncStatus is "error". */
|
||||||
|
lastSyncErrorMessage?: string;
|
||||||
/** Masked config for form prefill: secret fields are "***", others have saved values. */
|
/** Masked config for form prefill: secret fields are "***", others have saved values. */
|
||||||
configMasked?: Record<string, string>;
|
configMasked?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ export interface FormGeneratorControlsProps {
|
||||||
// Delete handlers
|
// Delete handlers
|
||||||
onDeleteSingle?: () => void;
|
onDeleteSingle?: () => void;
|
||||||
onDeleteMultiple?: () => void;
|
onDeleteMultiple?: () => void;
|
||||||
|
|
||||||
|
// Optional batch actions (e.g. "Sync to Accounting") – shown when items are selected
|
||||||
|
batchActions?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}[];
|
||||||
|
|
||||||
// Refresh handler
|
// Refresh handler
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
|
@ -76,6 +84,7 @@ export function FormGeneratorControls({
|
||||||
displayData,
|
displayData,
|
||||||
onDeleteSingle,
|
onDeleteSingle,
|
||||||
onDeleteMultiple,
|
onDeleteMultiple,
|
||||||
|
batchActions,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
searchable = true,
|
searchable = true,
|
||||||
selectable = true,
|
selectable = true,
|
||||||
|
|
@ -127,6 +136,18 @@ export function FormGeneratorControls({
|
||||||
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{batchActions?.map((action, idx) => (
|
||||||
|
<Button
|
||||||
|
key={idx}
|
||||||
|
onClick={action.onClick}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={action.icon}
|
||||||
|
disabled={action.loading}
|
||||||
|
>
|
||||||
|
{action.loading ? t('common.loading', 'Loading...') : action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,13 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
}[];
|
}[];
|
||||||
onDelete?: (row: T) => void;
|
onDelete?: (row: T) => void;
|
||||||
onDeleteMultiple?: (rows: T[]) => void;
|
onDeleteMultiple?: (rows: T[]) => void;
|
||||||
|
/** Batch actions shown when rows are selected (e.g. Sync to Accounting). Selection is cleared on success. */
|
||||||
|
batchActions?: {
|
||||||
|
label: string;
|
||||||
|
onClick: (rows: T[]) => void | Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}[];
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||||
|
|
@ -189,6 +196,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
customActions = [],
|
customActions = [],
|
||||||
onDelete,
|
onDelete,
|
||||||
onDeleteMultiple,
|
onDeleteMultiple,
|
||||||
|
batchActions = [],
|
||||||
onRefresh,
|
onRefresh,
|
||||||
className = '',
|
className = '',
|
||||||
getRowDataAttributes,
|
getRowDataAttributes,
|
||||||
|
|
@ -1387,6 +1395,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
// Format cell value
|
// Format cell value
|
||||||
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
const formatCellValue = (value: any, column: ColumnConfig, row: T) => {
|
||||||
|
// Custom formatter must run even when value is null/undefined (e.g. synthetic columns like _documentRefs)
|
||||||
|
if (column.formatter) {
|
||||||
|
return column.formatter(value, row);
|
||||||
|
}
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
|
|
@ -1495,11 +1508,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return displayValues.join(', ');
|
return displayValues.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use custom formatter if provided (but only if not an ID/hash field)
|
|
||||||
if (column.formatter) {
|
|
||||||
return column.formatter(value, row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a timestamp field based on name OR explicit type
|
// Check if this is a timestamp field based on name OR explicit type
|
||||||
// Do NOT treat arbitrary numbers as timestamps - only if field name suggests it
|
// Do NOT treat arbitrary numbers as timestamps - only if field name suggests it
|
||||||
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked|valuta)$/i.test(column.key);
|
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked|valuta)$/i.test(column.key);
|
||||||
|
|
@ -1644,6 +1652,21 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
|
const allSelected = selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
|
||||||
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
|
return (selectedRows.size > 1 || allSelected) ? handleDeleteMultiple : undefined;
|
||||||
})()}
|
})()}
|
||||||
|
batchActions={batchActions.length > 0 ? batchActions.map((ba) => ({
|
||||||
|
label: ba.label,
|
||||||
|
icon: ba.icon,
|
||||||
|
loading: ba.loading,
|
||||||
|
onClick: async () => {
|
||||||
|
const rows = Array.from(selectedRows).map((i) => displayData[i]);
|
||||||
|
try {
|
||||||
|
await Promise.resolve(ba.onClick(rows));
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
onRowSelect?.([]);
|
||||||
|
} catch {
|
||||||
|
// Keep selection on error so user can retry
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})) : undefined}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,16 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.automation-events': <FaClock />,
|
'page.admin.automation-events': <FaClock />,
|
||||||
'page.admin.logs': <FaFileAlt />,
|
'page.admin.logs': <FaFileAlt />,
|
||||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||||
|
'page.admin.mandateWizard': <FaHatWizard />,
|
||||||
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
||||||
|
'page.admin.invitationWizard': <FaEnvelopeOpenText />,
|
||||||
|
|
||||||
// Feature pages - Trustee
|
// Feature pages - Trustee
|
||||||
'page.feature.trustee.dashboard': <FaChartLine />,
|
'page.feature.trustee.dashboard': <FaChartLine />,
|
||||||
'page.feature.trustee.positions': <FaDatabase />,
|
'page.feature.trustee.positions': <FaDatabase />,
|
||||||
'page.feature.trustee.documents': <FaFileAlt />,
|
'page.feature.trustee.documents': <FaFileAlt />,
|
||||||
'page.feature.trustee.expense-import': <FaFileAlt />,
|
'page.feature.trustee.expense-import': <FaFileAlt />,
|
||||||
|
'page.feature.trustee.scan-upload': <FaFileAlt />,
|
||||||
'page.feature.trustee.instance-roles': <FaUserShield />,
|
'page.feature.trustee.instance-roles': <FaUserShield />,
|
||||||
'page.feature.trustee.settings': <FaCog />,
|
'page.feature.trustee.settings': <FaCog />,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ interface FileContextType {
|
||||||
downloadingFiles: Set<string>;
|
downloadingFiles: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileContext = createContext<FileContextType | undefined>(undefined);
|
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function FileProvider({ children }: { children: React.ReactNode }) {
|
export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { data: files, loading, error, refetch: refetchFiles, removeFileOptimistically } = useUserFiles();
|
const { data: files, loading, error, refetch: refetchFiles, removeFileOptimistically } = useUserFiles();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
|
||||||
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
||||||
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
||||||
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
|
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
|
||||||
|
import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
|
||||||
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
||||||
|
|
||||||
// Chatbot Views
|
// Chatbot Views
|
||||||
|
|
@ -97,6 +98,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
positions: TrusteePositionsView,
|
positions: TrusteePositionsView,
|
||||||
'instance-roles': TrusteeInstanceRolesView,
|
'instance-roles': TrusteeInstanceRolesView,
|
||||||
'expense-import': TrusteeExpenseImportView,
|
'expense-import': TrusteeExpenseImportView,
|
||||||
|
'scan-upload': TrusteeScanUploadView,
|
||||||
settings: TrusteeAccountingSettingsView,
|
settings: TrusteeAccountingSettingsView,
|
||||||
},
|
},
|
||||||
chatworkflow: {
|
chatworkflow: {
|
||||||
|
|
|
||||||
|
|
@ -151,14 +151,44 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{existingConfig?.configured && (
|
{existingConfig?.configured && (
|
||||||
<div className={styles.successMessage} style={{ marginBottom: '1rem' }}>
|
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||||||
<strong>Connected:</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
<strong>Connected:</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||||
{existingConfig.lastSyncStatus && (
|
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||||
<> — Last sync: {existingConfig.lastSyncStatus}</>
|
<> · Last sync: {existingConfig.lastSyncStatus}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{existingConfig?.configured && (existingConfig.lastSyncAt != null || existingConfig.lastSyncStatus != null) && (
|
||||||
|
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||||
|
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
<h4 style={{ marginTop: 0 }}>Sync-Status / Fehlerprotokoll</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{existingConfig.lastSyncAt != null && (
|
||||||
|
<div style={{ fontSize: '0.9rem' }}>
|
||||||
|
<strong>Letzter Sync:</strong>{' '}
|
||||||
|
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
||||||
|
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||||
|
<> · Status: {existingConfig.lastSyncStatus}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{existingConfig.lastSyncStatus === 'error' && (existingConfig.lastSyncErrorMessage ?? '').trim() !== '' && (
|
||||||
|
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||||||
|
{existingConfig.lastSyncErrorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{existingConfig.lastSyncStatus === 'error' && (!existingConfig.lastSyncErrorMessage || existingConfig.lastSyncErrorMessage.trim() === '') && (
|
||||||
|
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||||||
|
Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Step 1: Select system */}
|
{/* Step 1: Select system */}
|
||||||
<div className={styles.setupStep}>
|
<div className={styles.setupStep}>
|
||||||
<div className={styles.stepNumber}>1</div>
|
<div className={styles.stepNumber}>1</div>
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
const [existingAutomation, setExistingAutomation] = useState<ExistingAutomation | null>(null);
|
const [existingAutomation, setExistingAutomation] = useState<ExistingAutomation | null>(null);
|
||||||
const [isLoadingAutomation, setIsLoadingAutomation] = useState(true);
|
const [isLoadingAutomation, setIsLoadingAutomation] = useState(true);
|
||||||
const [showInfoTooltip, setShowInfoTooltip] = useState(false);
|
const [showInfoTooltip, setShowInfoTooltip] = useState(false);
|
||||||
|
const [isRunningNow, setIsRunningNow] = useState(false);
|
||||||
|
|
||||||
// Find all active Microsoft connections
|
// Find all active Microsoft connections
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -318,6 +319,41 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildTrusteeTemplate = useCallback((connectionRef: string, folder: string) => ({
|
||||||
|
overview: "Trustee document pipeline: extract, process, sync to accounting",
|
||||||
|
tasks: [{
|
||||||
|
id: "Task01",
|
||||||
|
title: "Trustee expense import",
|
||||||
|
description: "Extract from SharePoint, create positions, sync to accounting",
|
||||||
|
objective: "Run trustee.extractFromFiles, processDocuments, syncToAccounting",
|
||||||
|
actionList: [
|
||||||
|
{
|
||||||
|
execMethod: "trustee",
|
||||||
|
execAction: "extractFromFiles",
|
||||||
|
execParameters: {
|
||||||
|
connectionReference: connectionRef,
|
||||||
|
sharepointFolder: folder,
|
||||||
|
featureInstanceId: instanceId,
|
||||||
|
prompt: DEFAULT_EXTRACTION_PROMPT
|
||||||
|
},
|
||||||
|
execResultLabel: "extract_result"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
execMethod: "trustee",
|
||||||
|
execAction: "processDocuments",
|
||||||
|
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||||
|
execResultLabel: "process_result"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
execMethod: "trustee",
|
||||||
|
execAction: "syncToAccounting",
|
||||||
|
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||||
|
execResultLabel: "sync_result"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}), [instanceId]);
|
||||||
|
|
||||||
const handleSave = async (activate: boolean = true) => {
|
const handleSave = async (activate: boolean = true) => {
|
||||||
// Validate required fields with user feedback
|
// Validate required fields with user feedback
|
||||||
|
|
@ -341,29 +377,11 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
const connectionReference = `connection:msft:${msftConnection.externalUsername || msftConnection.accountName || msftConnection.id}`;
|
const connectionReference = `connection:msft:${msftConnection.externalUsername || msftConnection.accountName || msftConnection.id}`;
|
||||||
|
|
||||||
|
const template = buildTrusteeTemplate(connectionReference, selectedFolder);
|
||||||
const automationData = {
|
const automationData = {
|
||||||
label: 'Expense Import',
|
label: 'Expense Import',
|
||||||
schedule: '0 22 * * *', // Daily at 22:00
|
schedule: '0 22 * * *', // Daily at 22:00
|
||||||
template: JSON.stringify({
|
template: JSON.stringify(template),
|
||||||
overview: "Expenses PDF to Trustee Position",
|
|
||||||
tasks: [{
|
|
||||||
id: "Task01",
|
|
||||||
title: "Extract Expenses from SharePoint PDFs",
|
|
||||||
description: "Automatic expense extraction",
|
|
||||||
objective: "Extract expense data from PDF documents",
|
|
||||||
actionList: [{
|
|
||||||
execMethod: "sharepoint",
|
|
||||||
execAction: "getExpensesFromPdf",
|
|
||||||
execParameters: {
|
|
||||||
connectionReference: connectionReference,
|
|
||||||
sharepointFolder: selectedFolder,
|
|
||||||
featureInstanceId: instanceId,
|
|
||||||
prompt: DEFAULT_EXTRACTION_PROMPT
|
|
||||||
},
|
|
||||||
execResultLabel: "expense_extraction_result"
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
placeholders: {
|
placeholders: {
|
||||||
connectionName: connectionReference,
|
connectionName: connectionReference,
|
||||||
sharepointFolder: selectedFolder,
|
sharepointFolder: selectedFolder,
|
||||||
|
|
@ -414,7 +432,29 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
setIsActivating(false);
|
setIsActivating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRunNow = async () => {
|
||||||
|
if (!msftConnection || !selectedFolder || !instanceId) {
|
||||||
|
showError('Missing data', 'Please select connection and folder first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsRunningNow(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const connectionRef = getConnectionReference(msftConnection);
|
||||||
|
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
|
||||||
|
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||||
|
await api.post(`/api/chatplayground/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
|
||||||
|
showSuccess('Started', 'Workflow started. Extract → Process → Sync will run once.');
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||||
|
setError(msg);
|
||||||
|
showError('Error', msg);
|
||||||
|
} finally {
|
||||||
|
setIsRunningNow(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeactivate = async () => {
|
const handleDeactivate = async () => {
|
||||||
if (!existingAutomation) return;
|
if (!existingAutomation) return;
|
||||||
|
|
||||||
|
|
@ -662,6 +702,13 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
>
|
>
|
||||||
{isActivating ? 'Saving...' : (existingAutomation ? 'Save & Activate' : 'Activate Daily Import')}
|
{isActivating ? 'Saving...' : (existingAutomation ? 'Save & Activate' : 'Activate Daily Import')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={handleRunNow}
|
||||||
|
disabled={isActivating || isRunningNow}
|
||||||
|
>
|
||||||
|
{isRunningNow ? 'Starting...' : 'Jetzt ausführen'}
|
||||||
|
</button>
|
||||||
{existingAutomation && existingAutomation.active && (
|
{existingAutomation && existingAutomation.active && (
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,26 @@
|
||||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
* Verwendet FormGeneratorTable für konsistentes UI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee';
|
import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaSync, FaReceipt } from 'react-icons/fa';
|
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 styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
|
||||||
export const TrusteePositionsView: React.FC = () => {
|
export const TrusteePositionsView: React.FC = () => {
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { showError, showSuccess } = useToast();
|
||||||
|
const [downloadingDocIds, setDownloadingDocIds] = useState<Set<string>>(new Set());
|
||||||
|
const [syncStatusItems, setSyncStatusItems] = useState<AccountingSyncStatus[]>([]);
|
||||||
|
const [syncingPositionIds, setSyncingPositionIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Entity hook
|
// Entity hook
|
||||||
const {
|
const {
|
||||||
items: positions,
|
items: positions,
|
||||||
|
|
@ -49,10 +58,211 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
||||||
// Hidden columns (not shown in table view, but available in edit form)
|
// Load sync status for Sync-Status column
|
||||||
const hiddenColumns = ['desc', 'featureInstanceId', 'mandateId', 'taxCode', 'costCenter'];
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetchSyncStatus(request, instanceId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled && data?.items) setSyncStatusItems(data.items);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
// Generate columns from attributes + add system columns
|
const _reloadSyncStatus = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
fetchSyncStatus(request, instanceId)
|
||||||
|
.then((data) => data?.items && setSyncStatusItems(data.items))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const handleBatchSyncToAccounting = useCallback(
|
||||||
|
async (rows: TrusteePosition[]) => {
|
||||||
|
if (!instanceId || rows.length === 0) return;
|
||||||
|
const ids = new Set(rows.map((r) => r.id));
|
||||||
|
setSyncingPositionIds(ids);
|
||||||
|
try {
|
||||||
|
const res = await syncPositionsToAccounting(request, instanceId, rows.map((r) => r.id));
|
||||||
|
if (res.errors === 0) {
|
||||||
|
showSuccess('Sync', `${res.success} Position(en) erfolgreich synchronisiert.`);
|
||||||
|
} else if (res.success > 0) {
|
||||||
|
showError('Sync teilweise fehlgeschlagen', `${res.success} OK, ${res.errors} Fehler.`);
|
||||||
|
} else {
|
||||||
|
const firstError = res.results?.find((r: any) => !r.success);
|
||||||
|
showError('Sync fehlgeschlagen', firstError?.errorMessage || `${res.errors} Fehler.`);
|
||||||
|
}
|
||||||
|
refetch();
|
||||||
|
_reloadSyncStatus();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError('Sync fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler.');
|
||||||
|
} finally {
|
||||||
|
setSyncingPositionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
ids.forEach((id) => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instanceId, request, refetch, _reloadSyncStatus, showSuccess, showError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSingleSyncToAccounting = useCallback(
|
||||||
|
async (row: TrusteePosition) => {
|
||||||
|
await handleBatchSyncToAccounting([row]);
|
||||||
|
},
|
||||||
|
[handleBatchSyncToAccounting]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Document download: same as TrusteeDocumentsView – first load document metadata, then /data blob with correct MIME type and filename
|
||||||
|
const handleDownloadDocument = useCallback(
|
||||||
|
async (documentId: string) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setDownloadingDocIds(prev => new Set(prev).add(documentId));
|
||||||
|
try {
|
||||||
|
const docRes = await api.get(`/api/trustee/${instanceId}/documents/${documentId}`);
|
||||||
|
const doc = docRes.data;
|
||||||
|
if (!doc) {
|
||||||
|
showError('Fehler', 'Dokument nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await api.get(
|
||||||
|
`/api/trustee/${instanceId}/documents/${documentId}/data`,
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
);
|
||||||
|
const blob = new Blob([response.data], { type: doc.documentMimeType });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = doc.documentName || 'document';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download error:', err);
|
||||||
|
showError('Fehler', 'Fehler beim Herunterladen des Dokuments.');
|
||||||
|
} finally {
|
||||||
|
setDownloadingDocIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(documentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instanceId, showError]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hidden columns (not shown in table view, but available in edit form). documentId hidden – use Belege column instead.
|
||||||
|
const hiddenColumns = ['desc', 'documentId', 'featureInstanceId', 'mandateId', 'taxCode', 'costCenter'];
|
||||||
|
|
||||||
|
// Belege column: icon-only download (Beleg, optional later: Bank-Referenz). Max 0, 1 or 2 docs per position.
|
||||||
|
const belegeColumn: ColumnConfig = useMemo(() => ({
|
||||||
|
key: '_documentRefs',
|
||||||
|
label: 'Belege',
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
searchable: false,
|
||||||
|
width: 56,
|
||||||
|
minWidth: 48,
|
||||||
|
maxWidth: 80,
|
||||||
|
formatter: (_value: unknown, row: TrusteePosition) => {
|
||||||
|
const docIds: string[] = [row.documentId, row.bankDocumentId].filter(Boolean) as string[];
|
||||||
|
if (docIds.length === 0) return <span style={{ color: 'var(--text-secondary)' }}>—</span>;
|
||||||
|
const labels = ['Beleg', 'Bank-Referenz'];
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', gap: '4px', alignItems: 'center' }}>
|
||||||
|
{docIds.map((id, i) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDownloadDocument(id);
|
||||||
|
}}
|
||||||
|
disabled={downloadingDocIds.has(id)}
|
||||||
|
title={labels[i] ?? `Dokument ${i + 1}`}
|
||||||
|
style={{
|
||||||
|
padding: '4px',
|
||||||
|
minWidth: 28,
|
||||||
|
height: 28,
|
||||||
|
border: '1px solid var(--border-color, #e5e7eb)',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'var(--bg-secondary, #f9fafb)',
|
||||||
|
cursor: downloadingDocIds.has(id) ? 'wait' : 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{downloadingDocIds.has(id) ? (
|
||||||
|
<span style={{ fontSize: 12 }}>…</span>
|
||||||
|
) : (
|
||||||
|
<FaDownload size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}), [handleDownloadDocument, downloadingDocIds]);
|
||||||
|
|
||||||
|
// Map positionId -> display sync status: prefer synced over error (successful retry hides old error)
|
||||||
|
const syncByPosition = useMemo(() => {
|
||||||
|
const m = new Map<string, { syncStatus: string; errorMessage?: string }>();
|
||||||
|
for (const s of syncStatusItems) {
|
||||||
|
const cur = m.get(s.positionId);
|
||||||
|
const prefer =
|
||||||
|
!cur ||
|
||||||
|
s.syncStatus === 'synced' ||
|
||||||
|
(cur.syncStatus !== 'synced' && s.syncStatus === 'error');
|
||||||
|
if (prefer) m.set(s.positionId, { syncStatus: s.syncStatus, errorMessage: s.errorMessage });
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [syncStatusItems]);
|
||||||
|
|
||||||
|
const syncStatusColumn: ColumnConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
key: '_syncStatus',
|
||||||
|
label: 'Sync-Status',
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
searchable: false,
|
||||||
|
width: 160,
|
||||||
|
minWidth: 100,
|
||||||
|
maxWidth: 280,
|
||||||
|
formatter: (_value: unknown, row: TrusteePosition) => {
|
||||||
|
const info = syncByPosition.get(row.id);
|
||||||
|
if (!info)
|
||||||
|
return <span style={{ color: 'var(--text-secondary)' }}>—</span>;
|
||||||
|
if (info.syncStatus === 'error')
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={info.errorMessage || ''}
|
||||||
|
style={{ color: 'var(--error-color, #dc2626)' }}
|
||||||
|
>
|
||||||
|
Fehler{info.errorMessage ? ': ' + (info.errorMessage.length > 40 ? info.errorMessage.slice(0, 37) + '…' : info.errorMessage) : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (info.syncStatus === 'synced')
|
||||||
|
return <span style={{ color: 'var(--success-color, #16a34a)' }}>Synchronisiert</span>;
|
||||||
|
return <span>{info.syncStatus}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[syncByPosition]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Desired column order: Belege (icons), Sync-Status, Erstellt am, Valuta, Tags, Company, then the rest
|
||||||
|
const positionColumnOrder = [
|
||||||
|
'_documentRefs', // Belege (download icons)
|
||||||
|
'_syncStatus', // Sync-Status
|
||||||
|
'_createdAt', // Erstellt am
|
||||||
|
'valuta', // Valuta date
|
||||||
|
'tags',
|
||||||
|
'company',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate columns from attributes + synthetic columns, then reorder
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const attrColumns = (attributes || [])
|
const attrColumns = (attributes || [])
|
||||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||||
|
|
@ -67,9 +277,8 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
minWidth: attr.minWidth || 100,
|
minWidth: attr.minWidth || 100,
|
||||||
maxWidth: attr.maxWidth || 400,
|
maxWidth: attr.maxWidth || 400,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add _createdAt system column
|
const createdAtCol = {
|
||||||
attrColumns.push({
|
|
||||||
key: '_createdAt',
|
key: '_createdAt',
|
||||||
label: 'Erstellt am',
|
label: 'Erstellt am',
|
||||||
type: 'timestamp' as any,
|
type: 'timestamp' as any,
|
||||||
|
|
@ -79,10 +288,26 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
width: 150,
|
width: 150,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
});
|
};
|
||||||
|
|
||||||
return attrColumns;
|
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn, createdAtCol];
|
||||||
}, [attributes]);
|
const byKey = new Map(allColumns.map(c => [c.key, c]));
|
||||||
|
|
||||||
|
const ordered: typeof allColumns = [];
|
||||||
|
for (const key of positionColumnOrder) {
|
||||||
|
const col = byKey.get(key);
|
||||||
|
if (col) {
|
||||||
|
ordered.push(col);
|
||||||
|
byKey.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const restKeys = allColumns.map(c => c.key).filter(k => byKey.has(k));
|
||||||
|
for (const key of restKeys) {
|
||||||
|
const col = byKey.get(key);
|
||||||
|
if (col) ordered.push(col);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [attributes, belegeColumn, syncStatusColumn]);
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
|
@ -229,7 +454,15 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
searchable={true}
|
searchable={true}
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={false}
|
selectable={true}
|
||||||
|
batchActions={[
|
||||||
|
{
|
||||||
|
label: 'Buchhaltung synchronisieren',
|
||||||
|
icon: <FaSync />,
|
||||||
|
loading: syncingPositionIds.size > 0,
|
||||||
|
onClick: handleBatchSyncToAccounting,
|
||||||
|
},
|
||||||
|
]}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
...(canUpdate ? [{
|
...(canUpdate ? [{
|
||||||
type: 'edit' as const,
|
type: 'edit' as const,
|
||||||
|
|
@ -242,6 +475,15 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
loading: (row: TrusteePosition) => deletingItems.has(row.id),
|
loading: (row: TrusteePosition) => deletingItems.has(row.id),
|
||||||
}] : []),
|
}] : []),
|
||||||
]}
|
]}
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'sync-accounting',
|
||||||
|
icon: <FaSync />,
|
||||||
|
title: 'In Buchhaltung synchronisieren',
|
||||||
|
onClick: handleSingleSyncToAccounting,
|
||||||
|
loading: (row: TrusteePosition) => syncingPositionIds.has(row.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
onDelete={handleDeletePos}
|
onDelete={handleDeletePos}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch,
|
||||||
|
|
|
||||||
272
src/pages/views/trustee/TrusteeScanUploadView.tsx
Normal file
272
src/pages/views/trustee/TrusteeScanUploadView.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
/**
|
||||||
|
* TrusteeScanUploadView (UC1)
|
||||||
|
*
|
||||||
|
* Mobile-friendly scan/upload: photo, drag-and-drop, or file picker.
|
||||||
|
* Uploads files, then starts the trustee pipeline (extract → process → sync) with fileIds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useContext } from 'react';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { FileContext } from '../../../contexts/FileContext';
|
||||||
|
import api from '../../../api';
|
||||||
|
import styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
|
const DEFAULT_EXTRACTION_PROMPT = `Extrahiere Spesendaten aus dem Dokument. Gib Dokumenttyp (INVOICE, EXPENSE_RECEIPT, …) und Datensätze zurück. CSV-Spalten: valuta,company,desc,bookingAmount,bookingCurrency,vatPercentage,vatAmount,debitAccountNumber,creditAccountNumber,tags.`;
|
||||||
|
|
||||||
|
interface UploadedEntry {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseErrorDetail = (detail: unknown): string => {
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return (detail as Array<{ msg?: string }>).map((e) => e.msg || JSON.stringify(e)).join(', ');
|
||||||
|
}
|
||||||
|
if (detail && typeof detail === 'object' && 'msg' in (detail as object)) return (detail as { msg: string }).msg;
|
||||||
|
if (detail && typeof detail === 'object' && 'message' in (detail as object)) return (detail as { message: string }).message;
|
||||||
|
return String(detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TrusteeScanUploadView: React.FC = () => {
|
||||||
|
const { instanceId } = useCurrentInstance();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const fileContext = useContext(FileContext);
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedEntry[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = fileContext?.handleFileUpload;
|
||||||
|
const uploadingFile = fileContext?.uploadingFile ?? false;
|
||||||
|
|
||||||
|
const addFile = useCallback((fileId: string, fileName: string) => {
|
||||||
|
setUploadedFiles((prev) => {
|
||||||
|
if (prev.some((f) => f.fileId === fileId)) return prev;
|
||||||
|
return [...prev, { fileId, fileName }];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeFile = useCallback((fileId: string) => {
|
||||||
|
setUploadedFiles((prev) => prev.filter((f) => f.fileId !== fileId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onFiles = useCallback(
|
||||||
|
async (files: FileList | null) => {
|
||||||
|
if (!files?.length || !handleFileUpload || !instanceId) return;
|
||||||
|
setError(null);
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
if (!name.endsWith('.pdf') && !name.endsWith('.jpg') && !name.endsWith('.jpeg')) {
|
||||||
|
showError('Invalid file', `${file.name}: only PDF and JPG are supported.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await handleFileUpload(file);
|
||||||
|
if (result.success && result.fileData) {
|
||||||
|
const data = result.fileData?.file || result.fileData;
|
||||||
|
const id = data?.id || result.fileData?.id;
|
||||||
|
if (id) addFile(id, file.name);
|
||||||
|
else showError('Upload', 'Could not get file ID from server.');
|
||||||
|
} else {
|
||||||
|
showError('Upload failed', result.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFileUpload, instanceId, showError, addFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
onFiles(e.dataTransfer.files);
|
||||||
|
},
|
||||||
|
[onFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
},
|
||||||
|
[onFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildTemplate = useCallback(
|
||||||
|
(fileIds: string[]) => ({
|
||||||
|
overview: 'Trustee pipeline from uploaded files',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'Task01',
|
||||||
|
title: 'Extract, process, sync',
|
||||||
|
objective: 'Run trustee pipeline on uploaded files',
|
||||||
|
actionList: [
|
||||||
|
{
|
||||||
|
execMethod: 'trustee',
|
||||||
|
execAction: 'extractFromFiles',
|
||||||
|
execParameters: {
|
||||||
|
fileIds,
|
||||||
|
featureInstanceId: instanceId,
|
||||||
|
prompt: DEFAULT_EXTRACTION_PROMPT,
|
||||||
|
},
|
||||||
|
execResultLabel: 'extract_result',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
execMethod: 'trustee',
|
||||||
|
execAction: 'processDocuments',
|
||||||
|
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||||
|
execResultLabel: 'process_result',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
execMethod: 'trustee',
|
||||||
|
execAction: 'syncToAccounting',
|
||||||
|
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||||
|
execResultLabel: 'sync_result',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[instanceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStart = useCallback(async () => {
|
||||||
|
if (!instanceId || uploadedFiles.length === 0) {
|
||||||
|
showError('Missing data', 'Please upload at least one PDF or JPG first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsStarting(true);
|
||||||
|
setError(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' } });
|
||||||
|
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';
|
||||||
|
setError(msg);
|
||||||
|
showError('Error', msg);
|
||||||
|
} finally {
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, uploadedFiles, buildTemplate, showSuccess, showError]);
|
||||||
|
|
||||||
|
if (!fileContext) {
|
||||||
|
return (
|
||||||
|
<div className={styles.listView}>
|
||||||
|
<p>File upload is not available in this context.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.listView}>
|
||||||
|
<div className={styles.expenseImportSection}>
|
||||||
|
<h3 className={styles.sectionTitle}>Scan / Upload</h3>
|
||||||
|
<p className={styles.sectionDescription}>
|
||||||
|
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>}
|
||||||
|
|
||||||
|
{/* Drop zone + file input */}
|
||||||
|
<div
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
style={{
|
||||||
|
border: `2px dashed ${isDragging ? 'var(--primary-color, #2563eb)' : 'var(--border-color, #d0d0d0)'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
background: isDragging ? 'var(--surface-color, #f0f4ff)' : 'var(--bg-secondary, #fafafa)',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: '0 0 1rem 0', fontSize: '0.9375rem' }}>
|
||||||
|
Drag files here or choose below. PDF and JPG only.
|
||||||
|
</p>
|
||||||
|
<label className={styles.primaryButton} style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.jpg,.jpeg,application/pdf,image/jpeg"
|
||||||
|
multiple
|
||||||
|
onChange={onInputChange}
|
||||||
|
disabled={uploadingFile}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
aria-label="Choose files"
|
||||||
|
/>
|
||||||
|
{uploadingFile ? 'Uploading…' : 'Choose files'}
|
||||||
|
</label>
|
||||||
|
{/* Mobile: optional camera capture */}
|
||||||
|
<label className={styles.secondaryButton} style={{ cursor: 'pointer', display: 'inline-block', marginLeft: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={onInputChange}
|
||||||
|
disabled={uploadingFile}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
aria-label="Take photo"
|
||||||
|
/>
|
||||||
|
Take photo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uploaded list */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className={styles.sectionDescription}>
|
||||||
|
<strong>{uploadedFiles.length}</strong> file(s) ready. Click Start to run the pipeline.
|
||||||
|
</p>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 1rem 0' }}>
|
||||||
|
{uploadedFiles.map(({ fileId, fileName }) => (
|
||||||
|
<li
|
||||||
|
key={fileId}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.875rem' }}>{fileName}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => removeFile(fileId)}
|
||||||
|
style={{ padding: '0.25rem 0.5rem', fontSize: '0.8125rem' }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={isStarting}
|
||||||
|
>
|
||||||
|
{isStarting ? 'Starting…' : 'Start pipeline'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrusteeScanUploadView;
|
||||||
|
|
@ -7,4 +7,5 @@ export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
||||||
export { TrusteePositionsView } from './TrusteePositionsView';
|
export { TrusteePositionsView } from './TrusteePositionsView';
|
||||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||||
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||||
|
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
||||||
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
|
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
|
||||||
{ code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-documents' },
|
{ code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-documents' },
|
||||||
{ code: 'expense-import', label: { de: 'Spesen Import', en: 'Expense Import' }, path: 'expense-import' },
|
{ code: 'expense-import', label: { de: 'Spesen Import', en: 'Expense Import' }, path: 'expense-import' },
|
||||||
|
{ code: 'scan-upload', label: { de: 'Scannen / Hochladen', en: 'Scan / Upload' }, path: 'scan-upload' },
|
||||||
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
||||||
|
{ code: 'settings', label: { de: 'Buchhaltungseinstellungen', en: 'Accounting Settings' }, path: 'settings' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
chatworkflow: {
|
chatworkflow: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue