ui-nyla/src/pages/views/trustee/TrusteeAnalyseView.tsx
2026-04-11 22:23:35 +02:00

309 lines
12 KiB
TypeScript

/**
* 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 } from 'react';
import { useSearchParams } 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';
// ---------------------------------------------------------------------------
// 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 '';
}
}
// ---------------------------------------------------------------------------
// 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 { 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);
// Load workflows for this instance once
useEffect(() => {
if (!instanceId) return;
const _load = async () => {
setWorkflowsLoading(true);
try {
const res = await api.get(`/api/workflows/${instanceId}/workflows`);
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/workflows/${instanceId}/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]);
// 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;
}
setRunState('starting');
setRunError(null);
setRunSummary(t('Workflow wird gestartet…'));
try {
const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
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, 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>
<button
className={styles.primaryButton}
onClick={_handleExecute}
disabled={runState === 'starting' || runState === 'running'}
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>
)}
</div>
</div>
</div>
);
};
export default TrusteeAnalyseView;