-
{existingAutomation ? 'Update Configuration' : 'Activate Daily Import'}
+
{existingWorkflow ? 'Update Configuration' : 'Activate Daily Import'}
PDF files in {selectedFolder} will be processed daily at 22:00.
Successfully processed files will be moved to a "processed" subfolder.
@@ -700,7 +678,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
onClick={() => handleSave(true)}
disabled={isActivating}
>
- {isActivating ? 'Saving...' : (existingAutomation ? 'Save & Activate' : 'Activate Daily Import')}
+ {isActivating ? 'Saving...' : (existingWorkflow ? 'Save & Activate' : 'Activate Daily Import')}
{
>
{isRunningNow ? 'Starting...' : 'Jetzt ausführen'}
- {existingAutomation && existingAutomation.active && (
+ {existingWorkflow && existingWorkflow.active && (
{
+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(', ');
@@ -42,10 +47,9 @@ export const TrusteeScanUploadView: React.FC = () => {
const [pipelineState, setPipelineState] = useState('idle');
const [pipelineSummary, setPipelineSummary] = useState('');
const [pipelineDetail, setPipelineDetail] = useState('');
- const [pipelineWorkflowId, setPipelineWorkflowId] = useState(null);
+ const [pipelineRunId, setPipelineRunId] = useState(null);
const [lastPollAt, setLastPollAt] = useState(null);
const pollTimerRef = useRef(null);
- const latestTimestampRef = useRef(null);
const isPollingRef = useRef(false);
const handleFileUpload = fileContext?.handleFileUpload;
@@ -114,44 +118,6 @@ export const TrusteeScanUploadView: React.FC = () => {
[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 stopPolling = useCallback(() => {
if (pollTimerRef.current !== null) {
window.clearInterval(pollTimerRef.current);
@@ -160,91 +126,46 @@ export const TrusteeScanUploadView: React.FC = () => {
isPollingRef.current = false;
}, []);
- const pollWorkflowStatus = useCallback(
- async (workflowId: string) => {
- if (!instanceId || !workflowId || isPollingRef.current) return;
+ const pollRunStatus = useCallback(
+ async (runId: string) => {
+ if (!instanceId || !runId || isPollingRef.current) return;
isPollingRef.current = true;
try {
- const chatDataRes = await api.get(
- `/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
- {
- params: latestTimestampRef.current
- ? { afterTimestamp: latestTimestampRef.current }
- : undefined,
- }
+ const stepsRes = await api.get(
+ `/api/workflows/${instanceId}/runs/${runId}/steps`
);
+ const steps = Array.isArray(stepsRes?.data?.steps) ? stepsRes.data.steps : [];
- const chatItems = Array.isArray(chatDataRes?.data?.items)
- ? chatDataRes.data.items
- : [];
+ const completedSteps = steps.filter((s: any) => s.status === 'completed');
+ const failedSteps = steps.filter((s: any) => s.status === 'failed');
+ const runningSteps = steps.filter((s: any) => s.status === 'running');
+ const latestStep = steps.length > 0 ? steps[steps.length - 1] : null;
- 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());
+ setPipelineSummary(
+ `Run ${runId.slice(0, 8)} — ${completedSteps.length}/${steps.length} steps completed`
+ );
- if (isCompleted) {
- setPipelineState('completed');
+ if (latestStep) {
+ const label = latestStep.nodeType || latestStep.nodeId || '';
+ const status = latestStep.status || '';
+ setPipelineDetail(`${label}: ${status}`);
+ }
+
+ if (failedSteps.length > 0) {
+ const failedStep = failedSteps[failedSteps.length - 1];
+ const errMsg = failedStep.error || 'Step failed';
+ setPipelineState('error');
+ setError(errMsg);
stopPolling();
- showSuccess(
- 'Pipeline completed',
- 'Extraction and processing workflow finished successfully.'
- );
+ showError('Pipeline error', errMsg);
return;
}
- if (rawStatus === 'error') {
- setPipelineState('error');
+
+ if (runningSteps.length === 0 && completedSteps.length === steps.length && steps.length > 0) {
+ setPipelineState('completed');
stopPolling();
- if (latestLog) {
- setError(latestLog);
- }
- showError(
- 'Pipeline error',
- latestLog || 'Workflow ended with status "error".'
- );
+ showSuccess('Pipeline completed', 'Extraction and processing workflow finished successfully.');
return;
}
@@ -254,15 +175,11 @@ export const TrusteeScanUploadView: React.FC = () => {
setPipelineState('running');
return;
}
- const msg =
- parseErrorDetail(pollErr.response?.data?.detail) ||
- pollErr.message ||
- 'Polling failed';
+ 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();
+ showError('Polling error', msg);
} finally {
isPollingRef.current = false;
}
@@ -271,19 +188,19 @@ export const TrusteeScanUploadView: React.FC = () => {
);
useEffect(() => {
- if (!instanceId || !pipelineWorkflowId || (pipelineState !== 'running' && pipelineState !== 'starting')) {
+ if (!instanceId || !pipelineRunId || (pipelineState !== 'running' && pipelineState !== 'starting')) {
return;
}
- void pollWorkflowStatus(pipelineWorkflowId);
+ void pollRunStatus(pipelineRunId);
pollTimerRef.current = window.setInterval(() => {
- void pollWorkflowStatus(pipelineWorkflowId);
+ void pollRunStatus(pipelineRunId);
}, 3000);
return () => {
stopPolling();
};
- }, [instanceId, pipelineWorkflowId, pipelineState, pollWorkflowStatus, stopPolling]);
+ }, [instanceId, pipelineRunId, pipelineState, pollRunStatus, stopPolling]);
useEffect(() => {
return () => {
@@ -301,33 +218,38 @@ export const TrusteeScanUploadView: React.FC = () => {
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`;
+ const graph = _buildScanUploadGraph(instanceId, fileIds, DEFAULT_EXTRACTION_PROMPT);
const response = await api.post(
- `/api/automations/${instanceId}/start`,
- { prompt },
- { params: { workflowMode: 'Automation' } }
+ `/api/workflows/${instanceId}/execute`,
+ { graph }
);
- const workflowId = response?.data?.id || response?.data?.workflowId || null;
- if (!workflowId) {
- throw new Error('Workflow started but no workflow ID was returned by backend.');
+ const runId = response?.data?.runId || null;
+ if (!runId) {
+ const success = response?.data?.success;
+ if (success) {
+ setPipelineState('completed');
+ setPipelineSummary('Pipeline completed (synchronous execution).');
+ showSuccess('Completed', 'Pipeline finished: Extract → Process → Sync.');
+ } else {
+ throw new Error(response?.data?.error || 'Workflow executed but no run ID returned.');
+ }
+ } else {
+ setPipelineRunId(runId);
+ setPipelineState('running');
+ setPipelineSummary(`Run ${runId.slice(0, 8)} started. Waiting for progress updates...`);
+ showSuccess('Started', 'Pipeline started: Extract → Process → Sync. You can follow progress in Workflows.');
}
- 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';
+ const msg = _parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
setPipelineState('error');
setError(msg);
showError('Error', msg);
} finally {
setIsStarting(false);
}
- }, [instanceId, uploadedFiles, buildTemplate, showSuccess, showError]);
+ }, [instanceId, uploadedFiles, showSuccess, showError]);
if (!fileContext) {
return (
@@ -387,7 +309,6 @@ export const TrusteeScanUploadView: React.FC = () => {
/>
{uploadingFile ? 'Uploading…' : 'Choose files'}
- {/* Mobile: optional camera capture */}
;
+ position: { x: number; y: number };
+}
+
+interface TrusteeGraphConnection {
+ source: string;
+ sourcePort: number;
+ target: string;
+ targetPort: number;
+}
+
+export interface TrusteeGraph {
+ nodes: TrusteeGraphNode[];
+ connections: TrusteeGraphConnection[];
+}
+
+/**
+ * Build a graph for the scan/upload pipeline (UC1):
+ * trigger.manual → trustee.extractFromFiles → trustee.processDocuments → trustee.syncToAccounting
+ */
+export function _buildScanUploadGraph(
+ trusteeInstanceId: string,
+ fileIds: string[],
+ extractionPrompt: string
+): TrusteeGraph {
+ const nodes: TrusteeGraphNode[] = [
+ {
+ id: 'trigger-manual',
+ type: 'trigger.manual',
+ label: 'Start',
+ _method: '',
+ _action: '',
+ parameters: {},
+ position: { x: 0, y: 0 },
+ },
+ {
+ id: 'extract',
+ type: 'trustee.extractFromFiles',
+ label: 'Extract Documents',
+ _method: 'trustee',
+ _action: 'extractFromFiles',
+ parameters: {
+ fileIds,
+ featureInstanceId: trusteeInstanceId,
+ prompt: extractionPrompt,
+ },
+ position: { x: 250, y: 0 },
+ },
+ {
+ id: 'process',
+ type: 'trustee.processDocuments',
+ label: 'Process Documents',
+ _method: 'trustee',
+ _action: 'processDocuments',
+ parameters: {
+ documentList: [],
+ featureInstanceId: trusteeInstanceId,
+ },
+ position: { x: 500, y: 0 },
+ },
+ {
+ id: 'sync',
+ type: 'trustee.syncToAccounting',
+ label: 'Sync to Accounting',
+ _method: 'trustee',
+ _action: 'syncToAccounting',
+ parameters: {
+ documentList: [],
+ featureInstanceId: trusteeInstanceId,
+ },
+ position: { x: 750, y: 0 },
+ },
+ ];
+
+ const connections: TrusteeGraphConnection[] = [
+ { source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
+ { source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
+ { source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
+ ];
+
+ return { nodes, connections };
+}
+
+/**
+ * Build a graph for the expense import pipeline (SharePoint-based):
+ * trigger.manual → trustee.extractFromFiles (SharePoint) → trustee.processDocuments → trustee.syncToAccounting
+ */
+export function _buildExpenseImportGraph(
+ trusteeInstanceId: string,
+ connectionReference: string,
+ sharepointFolder: string,
+ extractionPrompt: string
+): TrusteeGraph {
+ const nodes: TrusteeGraphNode[] = [
+ {
+ id: 'trigger-manual',
+ type: 'trigger.manual',
+ label: 'Start',
+ _method: '',
+ _action: '',
+ parameters: {},
+ position: { x: 0, y: 0 },
+ },
+ {
+ id: 'extract',
+ type: 'trustee.extractFromFiles',
+ label: 'Extract from SharePoint',
+ _method: 'trustee',
+ _action: 'extractFromFiles',
+ parameters: {
+ connectionReference,
+ sharepointFolder,
+ featureInstanceId: trusteeInstanceId,
+ prompt: extractionPrompt,
+ },
+ position: { x: 250, y: 0 },
+ },
+ {
+ id: 'process',
+ type: 'trustee.processDocuments',
+ label: 'Process Documents',
+ _method: 'trustee',
+ _action: 'processDocuments',
+ parameters: {
+ documentList: [],
+ featureInstanceId: trusteeInstanceId,
+ },
+ position: { x: 500, y: 0 },
+ },
+ {
+ id: 'sync',
+ type: 'trustee.syncToAccounting',
+ label: 'Sync to Accounting',
+ _method: 'trustee',
+ _action: 'syncToAccounting',
+ parameters: {
+ documentList: [],
+ featureInstanceId: trusteeInstanceId,
+ },
+ position: { x: 750, y: 0 },
+ },
+ ];
+
+ const connections: TrusteeGraphConnection[] = [
+ { source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
+ { source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
+ { source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
+ ];
+
+ return { nodes, connections };
+}
+
+/**
+ * Build a scheduled workflow graph for daily expense import:
+ * trigger.schedule (cron) → trustee.extractFromFiles → trustee.processDocuments → trustee.syncToAccounting
+ */
+export function _buildScheduledExpenseImportGraph(
+ trusteeInstanceId: string,
+ connectionReference: string,
+ sharepointFolder: string,
+ extractionPrompt: string,
+ cronExpression: string
+): TrusteeGraph {
+ const nodes: TrusteeGraphNode[] = [
+ {
+ id: 'trigger-schedule',
+ type: 'trigger.schedule',
+ label: 'Daily Schedule',
+ _method: '',
+ _action: '',
+ parameters: {
+ cron: cronExpression,
+ enabled: true,
+ },
+ position: { x: 0, y: 0 },
+ },
+ {
+ id: 'extract',
+ type: 'trustee.extractFromFiles',
+ label: 'Extract from SharePoint',
+ _method: 'trustee',
+ _action: 'extractFromFiles',
+ parameters: {
+ connectionReference,
+ sharepointFolder,
+ featureInstanceId: trusteeInstanceId,
+ prompt: extractionPrompt,
+ },
+ position: { x: 250, y: 0 },
+ },
+ {
+ id: 'process',
+ type: 'trustee.processDocuments',
+ label: 'Process Documents',
+ _method: 'trustee',
+ _action: 'processDocuments',
+ parameters: {
+ documentList: [],
+ featureInstanceId: trusteeInstanceId,
+ },
+ position: { x: 500, y: 0 },
+ },
+ {
+ id: 'sync',
+ type: 'trustee.syncToAccounting',
+ label: 'Sync to Accounting',
+ _method: 'trustee',
+ _action: 'syncToAccounting',
+ parameters: {
+ documentList: [],
+ featureInstanceId: trusteeInstanceId,
+ },
+ position: { x: 750, y: 0 },
+ },
+ ];
+
+ const connections: TrusteeGraphConnection[] = [
+ { source: 'trigger-schedule', sourcePort: 0, target: 'extract', targetPort: 0 },
+ { source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
+ { source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
+ ];
+
+ return { nodes, connections };
+}
diff --git a/src/providers/language/LanguageContext.tsx b/src/providers/language/LanguageContext.tsx
index 2424079..c7d1554 100644
--- a/src/providers/language/LanguageContext.tsx
+++ b/src/providers/language/LanguageContext.tsx
@@ -1,16 +1,20 @@
-import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
-import { Language, TranslationKeys, loadLanguage } from '../../locales';
+import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
+import { Language, TranslationKeys, loadLanguage, fetchAvailableLanguageCodes, I18nCodeInfo } from '../../locales';
import { getUserDataCache } from '../../utils/userCache';
export type { Language };
+type TranslateParams = Record;
+
interface LanguageContextType {
currentLanguage: Language;
setLanguage: (language: Language) => void;
- t: (key: string, fallback?: string) => string;
+ t: (key: string, paramsOrFallback?: TranslateParams | string) => string;
isLoading: boolean;
reloadLanguage: () => Promise;
+ availableLanguages: I18nCodeInfo[];
+ refreshAvailableLanguages: () => Promise;
}
const LanguageContext = createContext(undefined);
@@ -22,14 +26,19 @@ interface LanguageProviderProps {
export const LanguageProvider: React.FC = ({ children }) => {
const [currentLanguage, setCurrentLanguage] = useState('de');
const [translations, setTranslations] = useState({});
+ const [deTranslations, setDeTranslations] = useState({});
const [isLoading, setIsLoading] = useState(true);
+ const [availableLanguages, setAvailableLanguages] = useState([]);
// Function to load and set a language
const loadAndSetLanguage = async (language: Language) => {
setIsLoading(true);
try {
- const newTranslations = await loadLanguage(language);
- setTranslations(newTranslations);
+ const deKeys = await loadLanguage('de');
+ setDeTranslations(deKeys);
+ const targetKeys =
+ language === 'de' ? deKeys : await loadLanguage(language);
+ setTranslations(targetKeys);
setCurrentLanguage(language);
} catch (error) {
console.error('Failed to load language:', error);
@@ -46,8 +55,8 @@ export const LanguageProvider: React.FC = ({ children })
// Priority 1: Check if user data has language setting (ONLY source of truth!)
const userData = getUserDataCache();
- if (userData?.language && ['de', 'en', 'fr'].includes(userData.language)) {
- initialLanguage = userData.language as Language;
+ if (userData?.language && String(userData.language).trim()) {
+ initialLanguage = String(userData.language).trim() as Language;
console.log('🌍 Using language from user profile (sessionStorage cache):', initialLanguage);
await loadAndSetLanguage(initialLanguage);
return;
@@ -55,10 +64,16 @@ export const LanguageProvider: React.FC = ({ children })
// Priority 2: Detect browser language (fallback only if no user data)
const browserLang = navigator.language.split('-')[0] as Language;
- if (['de', 'en', 'fr'].includes(browserLang)) {
- initialLanguage = browserLang;
- console.log('🌍 Using browser language as fallback:', initialLanguage);
- } else {
+ try {
+ const codes = await fetchAvailableLanguageCodes();
+ const codeSet = new Set(codes.map((c) => c.code));
+ if (codeSet.has(browserLang)) {
+ initialLanguage = browserLang;
+ console.log('🌍 Using browser language as fallback:', initialLanguage);
+ } else {
+ console.log('🌍 Using default language:', initialLanguage);
+ }
+ } catch {
console.log('🌍 Using default language:', initialLanguage);
}
@@ -70,8 +85,8 @@ export const LanguageProvider: React.FC = ({ children })
// Listen for user data updates to sync language
const handleUserUpdate = () => {
const userData = getUserDataCache();
- if (userData?.language && ['de', 'en', 'fr'].includes(userData.language)) {
- const userLanguage = userData.language as Language;
+ if (userData?.language && String(userData.language).trim()) {
+ const userLanguage = String(userData.language).trim() as Language;
if (userLanguage !== currentLanguage) {
console.log('🔄 Syncing language with user data (sessionStorage cache):', userLanguage);
loadAndSetLanguage(userLanguage);
@@ -103,9 +118,43 @@ export const LanguageProvider: React.FC = ({ children })
await loadAndSetLanguage(currentLanguage);
};
- const t = (key: string, fallback?: string): string => {
- const translation = translations[key] || fallback || key;
- return translation;
+ const refreshAvailableLanguages = useCallback(async () => {
+ try {
+ const list = await fetchAvailableLanguageCodes();
+ setAvailableLanguages(list);
+ } catch (e) {
+ console.error('Failed to load language codes:', e);
+ }
+ }, []);
+
+ useEffect(() => {
+ refreshAvailableLanguages();
+ }, [refreshAvailableLanguages]);
+
+ const _applyParams = (template: string, params?: TranslateParams): string => {
+ if (!params) return template;
+ let out = template;
+ for (const [paramKey, rawVal] of Object.entries(params)) {
+ if (rawVal === undefined || rawVal === null) continue;
+ out = out.split(`{${paramKey}}`).join(String(rawVal));
+ }
+ return out;
+ };
+
+ const t = (key: string, paramsOrFallback?: TranslateParams | string): string => {
+ let params: TranslateParams | undefined;
+ if (typeof paramsOrFallback === 'string') {
+ params = undefined;
+ } else {
+ params = paramsOrFallback;
+ }
+
+ const resolved =
+ translations[key] ??
+ deTranslations[key] ??
+ (typeof paramsOrFallback === 'string' ? paramsOrFallback : undefined) ??
+ `[${key}]`;
+ return _applyParams(resolved, params);
};
const contextValue: LanguageContextType = {
@@ -113,7 +162,9 @@ export const LanguageProvider: React.FC = ({ children })
setLanguage,
t,
isLoading,
- reloadLanguage
+ reloadLanguage,
+ availableLanguages,
+ refreshAvailableLanguages,
};
return (