Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 56s
492 lines
19 KiB
TypeScript
492 lines
19 KiB
TypeScript
// Copyright (c) 2026 PowerOn AG
|
|
// All rights reserved.
|
|
/**
|
|
* TrusteeAnalyseView
|
|
*
|
|
* Tab-based analysis page. Each tab maps to a bootstrapped template workflow
|
|
* (created from TEMPLATE_WORKFLOWS when the feature instance was set up).
|
|
* The workflow is loaded from the instance, executed via the workflow engine,
|
|
* and results/status are shown inline with polling.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
|
import { useToast } from '../../../contexts/ToastContext';
|
|
import api from '../../../api';
|
|
import styles from './TrusteeViews.module.css';
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
import { FaUpload, FaTimes } from 'react-icons/fa';
|
|
import {
|
|
PeriodPicker,
|
|
resolvePeriod,
|
|
type PeriodDirection,
|
|
type PeriodPreset,
|
|
type PeriodValue,
|
|
} from '../../../components/PeriodPicker';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab definitions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TabDef {
|
|
id: string;
|
|
templateTag: string;
|
|
icon: string;
|
|
color: string;
|
|
}
|
|
|
|
const _TABS: TabDef[] = [
|
|
{ id: 'budget', templateTag: 'template:trustee-budget-comparison', icon: '\uD83D\uDCCA', color: '#2196F3' },
|
|
{ id: 'kpi', templateTag: 'template:trustee-kpi-dashboard', icon: '\uD83D\uDCF0', color: '#9C27B0' },
|
|
{ id: 'cashflow', templateTag: 'template:trustee-cashflow', icon: '\uD83D\uDCB0', color: '#009688' },
|
|
{ id: 'forecast', templateTag: 'template:trustee-forecast', icon: '\uD83D\uDCC8', color: '#E91E63' },
|
|
];
|
|
|
|
function _tabLabel(tabId: string, t: (k: string) => string): string {
|
|
switch (tabId) {
|
|
case 'budget': return t('Budget-Vergleich');
|
|
case 'kpi': return t('KPI-Dashboard');
|
|
case 'cashflow': return t('Cashflow-Rechnung');
|
|
case 'forecast': return t('Prognose');
|
|
default: return tabId;
|
|
}
|
|
}
|
|
|
|
function _tabDescription(tabId: string, t: (k: string) => string): string {
|
|
switch (tabId) {
|
|
case 'budget': return t('Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel');
|
|
case 'kpi': return t('Kennzahlen berechnen und visualisieren');
|
|
case 'cashflow': return t('Cashflow berechnen und analysieren');
|
|
case 'forecast': return t('Trend-Analyse und Prognose der nächsten Monate');
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
interface TabPeriodConfig {
|
|
defaultPreset: PeriodPreset;
|
|
direction: PeriodDirection;
|
|
}
|
|
|
|
function _periodConfigForTab(tabId: string): TabPeriodConfig {
|
|
switch (tabId) {
|
|
case 'forecast':
|
|
return { defaultPreset: { kind: 'next12Months' }, direction: 'future' };
|
|
case 'budget':
|
|
case 'kpi':
|
|
case 'cashflow':
|
|
default:
|
|
return { defaultPreset: { kind: 'ytd' }, direction: 'any' };
|
|
}
|
|
}
|
|
|
|
function _initialPeriodForTab(tabId: string): PeriodValue {
|
|
const cfg = _periodConfigForTab(tabId);
|
|
const r = resolvePeriod(cfg.defaultPreset);
|
|
return { preset: cfg.defaultPreset, fromDate: r.fromDate, toDate: r.toDate };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface WorkflowSummary {
|
|
id: string;
|
|
label: string;
|
|
tags: string[];
|
|
}
|
|
|
|
type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const TrusteeAnalyseView: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const navigate = useNavigate();
|
|
const { instanceId } = useCurrentInstance();
|
|
const { showSuccess, showError } = useToast();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const activeTab = searchParams.get('tab') || _TABS[0].id;
|
|
const _setActiveTab = useCallback((tab: string) => {
|
|
setSearchParams({ tab }, { replace: true });
|
|
}, [setSearchParams]);
|
|
|
|
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
|
const [workflowsLoading, setWorkflowsLoading] = useState(true);
|
|
|
|
const [runState, setRunState] = useState<RunState>('idle');
|
|
const [runId, setRunId] = useState<string | null>(null);
|
|
const [runSummary, setRunSummary] = useState('');
|
|
const [runError, setRunError] = useState<string | null>(null);
|
|
const pollTimerRef = useRef<number | null>(null);
|
|
const isPollingRef = useRef(false);
|
|
|
|
const [budgetFileId, setBudgetFileId] = useState<string | null>(null);
|
|
const [budgetFileName, setBudgetFileName] = useState<string | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// One PeriodValue per tab, defaults derived from `_periodConfigForTab`.
|
|
const [periodByTab, setPeriodByTab] = useState<Record<string, PeriodValue>>(() => {
|
|
const initial: Record<string, PeriodValue> = {};
|
|
for (const tab of _TABS) initial[tab.id] = _initialPeriodForTab(tab.id);
|
|
return initial;
|
|
});
|
|
const tabPeriodConfig = useMemo(() => _periodConfigForTab(activeTab), [activeTab]);
|
|
const currentPeriod = periodByTab[activeTab] || _initialPeriodForTab(activeTab);
|
|
const _setCurrentPeriod = useCallback((next: PeriodValue) => {
|
|
setPeriodByTab((prev) => ({ ...prev, [activeTab]: next }));
|
|
}, [activeTab]);
|
|
|
|
// Load workflows for this instance once
|
|
useEffect(() => {
|
|
if (!instanceId) return;
|
|
const _load = async () => {
|
|
setWorkflowsLoading(true);
|
|
try {
|
|
const res = await api.get(`/api/workflow-automation/workflows`, { params: { targetFeatureInstanceId: instanceId } });
|
|
const items: WorkflowSummary[] = (res.data?.workflows || res.data?.items || []).map((w: any) => ({
|
|
id: w.id,
|
|
label: w.label,
|
|
tags: w.tags || [],
|
|
}));
|
|
setWorkflows(items);
|
|
} catch {
|
|
setWorkflows([]);
|
|
} finally {
|
|
setWorkflowsLoading(false);
|
|
}
|
|
};
|
|
_load();
|
|
}, [instanceId]);
|
|
|
|
// Find the workflow for the active tab
|
|
const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
|
|
const tabDef = _TABS.find((tabItem) => tabItem.id === tab);
|
|
if (!tabDef) return undefined;
|
|
return workflows.find((w) => w.tags.includes(tabDef.templateTag));
|
|
}, [workflows]);
|
|
|
|
// Polling
|
|
const _stopPolling = useCallback(() => {
|
|
if (pollTimerRef.current !== null) {
|
|
window.clearInterval(pollTimerRef.current);
|
|
pollTimerRef.current = null;
|
|
}
|
|
isPollingRef.current = false;
|
|
}, []);
|
|
|
|
const _pollRun = useCallback(async (rid: string) => {
|
|
if (!instanceId || !rid || isPollingRef.current) return;
|
|
isPollingRef.current = true;
|
|
try {
|
|
const res = await api.get(`/api/workflow-automation/runs/${rid}/steps`);
|
|
const steps: any[] = Array.isArray(res?.data?.steps) ? res.data.steps : [];
|
|
const completed = steps.filter((s) => s.status === 'completed');
|
|
const failed = steps.filter((s) => s.status === 'failed');
|
|
const running = steps.filter((s) => s.status === 'running');
|
|
|
|
setRunSummary(`${completed.length}/${steps.length} ${t('Schritte abgeschlossen')}`);
|
|
|
|
if (failed.length > 0) {
|
|
const errMsg = failed[failed.length - 1].error || t('Schritt fehlgeschlagen');
|
|
setRunState('error');
|
|
setRunError(errMsg);
|
|
_stopPolling();
|
|
showError(t('Pipeline-Fehler'), errMsg);
|
|
return;
|
|
}
|
|
if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
|
|
setRunState('completed');
|
|
_stopPolling();
|
|
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
|
return;
|
|
}
|
|
setRunState('running');
|
|
} catch (err: any) {
|
|
if (err?.response?.status === 404) {
|
|
setRunState('running');
|
|
return;
|
|
}
|
|
setRunState('error');
|
|
setRunError(err.message || t('Abfrage fehlgeschlagen'));
|
|
_stopPolling();
|
|
} finally {
|
|
isPollingRef.current = false;
|
|
}
|
|
}, [instanceId, showError, showSuccess, _stopPolling, t]);
|
|
|
|
useEffect(() => {
|
|
if (!instanceId || !runId || (runState !== 'running' && runState !== 'starting')) return;
|
|
void _pollRun(runId);
|
|
pollTimerRef.current = window.setInterval(() => { void _pollRun(runId); }, 3000);
|
|
return () => { _stopPolling(); };
|
|
}, [instanceId, runId, runState, _pollRun, _stopPolling]);
|
|
|
|
useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
|
|
|
|
// Reset run state when tab changes
|
|
useEffect(() => {
|
|
_stopPolling();
|
|
setRunState('idle');
|
|
setRunId(null);
|
|
setRunSummary('');
|
|
setRunError(null);
|
|
}, [activeTab, _stopPolling]);
|
|
|
|
const _handleBudgetUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file || !instanceId) return;
|
|
setUploading(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('featureInstanceId', instanceId);
|
|
const res = await api.post('/api/files/upload', formData);
|
|
const fileData = res.data?.file || res.data;
|
|
setBudgetFileId(fileData.id);
|
|
setBudgetFileName(fileData.fileName || file.name);
|
|
showSuccess(t('Datei hochgeladen'), file.name);
|
|
} catch (err: any) {
|
|
showError(t('Upload fehlgeschlagen'), err.message || t('Datei konnte nicht hochgeladen werden.'));
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
}
|
|
}, [instanceId, showSuccess, showError, t]);
|
|
|
|
const _handleRemoveBudgetFile = useCallback(() => {
|
|
setBudgetFileId(null);
|
|
setBudgetFileName(null);
|
|
}, []);
|
|
|
|
// Execute workflow
|
|
const _handleExecute = useCallback(async () => {
|
|
const wf = _findWorkflow(activeTab);
|
|
if (!wf || !instanceId) {
|
|
showError(t('Fehler'), t('Kein Workflow für diesen Tab gefunden.'));
|
|
return;
|
|
}
|
|
if (activeTab === 'budget' && !budgetFileId) {
|
|
showError(t('Budget-Datei fehlt'), t('Bitte laden Sie zuerst die Budget-Excel-Datei hoch.'));
|
|
return;
|
|
}
|
|
setRunState('starting');
|
|
setRunError(null);
|
|
setRunSummary(t('Workflow wird gestartet…'));
|
|
try {
|
|
const executeBody: Record<string, any> = { workflowId: wf.id };
|
|
const payload: Record<string, any> = {
|
|
dateFrom: currentPeriod.fromDate,
|
|
dateTo: currentPeriod.toDate,
|
|
};
|
|
if (activeTab === 'budget' && budgetFileId) {
|
|
payload.documentList = [budgetFileId];
|
|
}
|
|
executeBody.payload = payload;
|
|
executeBody.targetInstanceId = instanceId;
|
|
const res = await api.post(`/api/workflow-automation/workflows/${wf.id}/execute`, executeBody);
|
|
const rid = res?.data?.runId;
|
|
if (rid) {
|
|
setRunId(rid);
|
|
setRunState('running');
|
|
setRunSummary(t('Lauf {prefix} gestartet', { prefix: rid.slice(0, 8) }));
|
|
} else if (res?.data?.success) {
|
|
setRunState('completed');
|
|
setRunSummary(t('Workflow synchron abgeschlossen.'));
|
|
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
|
} else {
|
|
throw new Error(res?.data?.error || t('Unerwartete Antwort'));
|
|
}
|
|
} catch (err: any) {
|
|
const msg = err?.response?.data?.detail || err.message || t('Workflow konnte nicht gestartet werden.');
|
|
setRunState('error');
|
|
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
|
|
}
|
|
}, [activeTab, instanceId, _findWorkflow, budgetFileId, currentPeriod, showError, showSuccess, t]);
|
|
|
|
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
|
|
const currentWorkflow = _findWorkflow(activeTab);
|
|
|
|
return (
|
|
<div className={styles.listView}>
|
|
<div className={styles.expenseImportSection}>
|
|
<h3 className={styles.sectionTitle}>{t('Analyse & Reporting')}</h3>
|
|
|
|
{/* Tab bar */}
|
|
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--border-color, #e0e0e0)', paddingBottom: 0 }}>
|
|
{_TABS.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => _setActiveTab(tab.id)}
|
|
style={{
|
|
padding: '0.625rem 1rem',
|
|
border: 'none',
|
|
borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
|
|
background: 'transparent',
|
|
color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
|
|
fontWeight: activeTab === tab.id ? 600 : 400,
|
|
fontSize: '0.875rem',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
marginBottom: '-2px',
|
|
}}
|
|
>
|
|
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
|
|
{_tabLabel(tab.id, t)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
<p className={styles.sectionDescription}>
|
|
{_tabDescription(activeTab, t)}
|
|
</p>
|
|
|
|
{workflowsLoading ? (
|
|
<p className={styles.loadingText}>{t('Workflows werden geladen…')}</p>
|
|
) : !currentWorkflow ? (
|
|
<div className={styles.infoBox}>
|
|
<p>{t('Für diesen Tab wurde kein Workflow in der Instanz gefunden. Der Workflow wird beim Erstellen der Instanz automatisch angelegt.')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
<span style={{ fontSize: '2rem' }}>{currentTab.icon}</span>
|
|
<div>
|
|
<div style={{ fontWeight: 600, color: 'var(--text-primary, #1a1a1a)' }}>{currentWorkflow.label}</div>
|
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-secondary, #666)' }}>
|
|
{t('Workflow-ID:')} {currentWorkflow.id.slice(0, 8)}…
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{activeTab === 'budget' && (
|
|
<div style={{
|
|
padding: '1rem',
|
|
border: '1px dashed var(--border-color, #ccc)',
|
|
borderRadius: '8px',
|
|
background: 'var(--bg-secondary, #f9f9f9)',
|
|
}}>
|
|
<div style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem' }}>
|
|
{t('Budget-Excel hochladen')}
|
|
</div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginBottom: '0.5rem' }}>
|
|
{t('Ergebnis: Excel-Bericht mit Konten-Tabelle, Uebersichts-Chart und Management-Summary.')}
|
|
</div>
|
|
{budgetFileName ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.875rem' }}>📄 {budgetFileName}</span>
|
|
<button
|
|
onClick={_handleRemoveBudgetFile}
|
|
style={{
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|
color: 'var(--text-secondary, #666)', padding: '0.25rem',
|
|
}}
|
|
title={t('Datei entfernen')}
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<label style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
|
|
padding: '0.5rem 1rem', borderRadius: '6px', cursor: uploading ? 'wait' : 'pointer',
|
|
border: '1px solid var(--border-color, #ccc)', background: 'var(--bg-primary, #fff)',
|
|
fontSize: '0.875rem', color: 'var(--text-primary, #333)',
|
|
}}>
|
|
<FaUpload />
|
|
{uploading ? t('Wird hochgeladen…') : t('Excel-Datei auswählen')}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".xlsx,.xls,.csv"
|
|
onChange={_handleBudgetUpload}
|
|
disabled={uploading}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
|
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--text-secondary, #4A5568)' }}>
|
|
{t('Zeitraum')}
|
|
</label>
|
|
<PeriodPicker
|
|
value={currentPeriod}
|
|
onChange={_setCurrentPeriod}
|
|
direction={tabPeriodConfig.direction}
|
|
defaultPreset={tabPeriodConfig.defaultPreset}
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
className={styles.primaryButton}
|
|
onClick={_handleExecute}
|
|
disabled={runState === 'starting' || runState === 'running' || (activeTab === 'budget' && !budgetFileId)}
|
|
style={{ alignSelf: 'flex-start' }}
|
|
>
|
|
{runState === 'starting' || runState === 'running'
|
|
? t('Läuft…')
|
|
: t('Ausführen')}
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Pipeline status */}
|
|
{runState !== 'idle' && (
|
|
<div className={runState === 'error' ? styles.errorMessage : styles.successMessage}>
|
|
<strong>{t('Status')}:</strong>{' '}
|
|
{runState === 'starting' && t('Wird gestartet…')}
|
|
{runState === 'running' && t('Läuft')}
|
|
{runState === 'completed' && t('Abgeschlossen')}
|
|
{runState === 'error' && t('Fehler')}
|
|
{runSummary && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
|
|
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{/* Workspace link (replaces inline results) */}
|
|
{runState === 'completed' && runId && (
|
|
<div style={{
|
|
marginTop: '0.5rem',
|
|
padding: '0.75rem 1rem',
|
|
border: '1px solid var(--border-color, #e0e0e0)',
|
|
borderRadius: '8px',
|
|
background: 'rgba(40,167,69,0.08)',
|
|
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
|
}}>
|
|
<span style={{ fontSize: '0.875rem', color: 'var(--success-color, #28a745)' }}>
|
|
{t('Workflow abgeschlossen.')}
|
|
</span>
|
|
<a
|
|
href={`/workflow-automation?tab=detail&runId=${runId}`}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
navigate(`/workflow-automation?tab=detail&runId=${runId}`);
|
|
}}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
|
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
|
background: 'var(--primary-color, #007bff)', color: '#fff',
|
|
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
|
}}
|
|
>
|
|
{t('Im Workspace ansehen')}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TrusteeAnalyseView;
|