frontend_nyla/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
2026-04-26 18:11:52 +02:00

450 lines
15 KiB
TypeScript

/**
* GraphicalEditorWorkflowsPage
* List of saved workflows with FormGeneratorTable.
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
* Filter: Alle | Aktiv | Inaktiv.
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchWorkflows,
deleteWorkflow,
executeGraph,
updateWorkflow,
importWorkflowFromFile,
exportWorkflowToFile,
isWorkflowFileContent,
workflowFileNameFor,
WORKFLOW_FILE_EXTENSION,
type Automation2Workflow,
type WorkflowFileEnvelope,
} from '../../../api/workflowApi';
import { fetchAttributes } from '../../../api/attributesApi';
import type { AttributeDefinition } from '../../../api/attributesApi';
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
function formatTs(ts?: number): string {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
const { time } = formatUnixTimestamp(sec, undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return time;
}
export const GraphicalEditorWorkflowsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { mandateId } = useParams<{ mandateId: string }>();
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [importing, setImporting] = useState(false);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
useEffect(() => {
fetchAttributes(request, 'Automation2Workflow')
.then(setBackendAttributes)
.catch(() => {});
}, [request]);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
setLoading(true);
try {
const active = activeFilter === 'active' ? true : activeFilter === 'inactive' ? false : undefined;
const result = await fetchWorkflows(request, instanceId, { active, pagination: paginationParams });
if (result && typeof result === 'object' && 'items' in result && !Array.isArray(result)) {
setWorkflows((result as any).items);
setPaginationMeta((result as any).pagination);
} else {
setWorkflows(result as Automation2Workflow[]);
setPaginationMeta(null);
}
} catch (e) {
console.error('[graphicalEditor] load workflows failed', e);
showError(t('Fehler beim Laden der Workflows'));
} finally {
setLoading(false);
}
}, [instanceId, request, showError, activeFilter, t]);
useEffect(() => {
load();
}, [load]);
const handleDelete = useCallback(
async (workflowId: string): Promise<boolean> => {
if (!instanceId) return false;
try {
await deleteWorkflow(request, instanceId, workflowId);
showSuccess(t('Workflow gelöscht'));
await load();
return true;
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
return false;
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleEdit = useCallback(
(row: Automation2Workflow) => {
if (!mandateId || !instanceId) return;
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
},
[mandateId, instanceId, navigate]
);
const hasManualTrigger = useCallback((row: Automation2Workflow): boolean => {
const invs = row.invocations || [];
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
}, []);
const handleToggleActive = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const next = !(row.active !== false);
setTogglingId(row.id);
try {
await updateWorkflow(request, instanceId, row.id, { active: next });
showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') }));
} finally {
setTogglingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleRename = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const newLabel = await promptInput(t('Neuer Name:'), {
title: t('Workflow umbenennen'),
defaultValue: row.label,
placeholder: t('Workflow-Name'),
});
if (!newLabel || newLabel.trim() === row.label) return;
try {
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
showSuccess(t('Workflow umbenannt'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
}
},
[instanceId, request, promptInput, showSuccess, showError, load, t]
);
const handleExecute = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
setExecutingId(row.id);
try {
const invs = row.invocations || [];
const primary =
invs.find((i) => i.enabled && i.kind === 'manual') ||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
const result = await executeGraph(request, instanceId, row.graph!, row.id, {
...(primary ? { entryPointId: primary.id } : {}),
});
if (result?.success) {
if (result?.paused) {
showSuccess(t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.'));
} else {
showSuccess(t('Workflow ausgeführt'));
}
await load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
} finally {
setExecutingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleExport = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
try {
const result = await exportWorkflowToFile(request, instanceId, row.id, false);
const fileName = result.fileName || workflowFileNameFor(row.label);
const blob = new Blob([JSON.stringify(result.envelope, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('Workflow als Datei exportiert'));
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') }));
}
},
[instanceId, request, showSuccess, showError, t],
);
const handleImportFileSelected = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !instanceId) return;
setImporting(true);
try {
const text = await file.text();
let envelope: WorkflowFileEnvelope;
try {
envelope = JSON.parse(text) as WorkflowFileEnvelope;
} catch {
showError(t('Datei ist kein gültiges JSON'));
return;
}
if (!isWorkflowFileContent(envelope)) {
showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION }));
return;
}
const result = await importWorkflowFromFile(request, instanceId, { envelope });
const warnings = result?.warnings ?? [];
if (warnings.length > 0) {
showSuccess(
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }),
);
} else {
showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.'));
}
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') }));
} finally {
setImporting(false);
}
},
[instanceId, request, showSuccess, showError, load, t],
);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Workflow'), width: 200, sortable: true },
{
key: 'active',
label: t('Aktiv (Spalte)'),
width: 80,
formatter: (value: boolean) =>
value !== false ? (
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>Ja</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
),
},
{
key: 'isRunning',
label: t('läuft'),
width: 80,
formatter: (value: boolean) =>
value ? (
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
),
},
{
key: 'stuckAtNodeLabel',
label: t('steht bei'),
width: 160,
formatter: (value: string, row: Automation2Workflow) =>
row.isRunning && (value || row.stuckAtNodeId)
? value || row.stuckAtNodeId || '—'
: '—',
},
{
key: 'createdAt',
label: t('Erstellt'),
width: 140,
formatter: (v: number) => formatTs(v),
},
{
key: 'lastStartedAt',
label: t('zuletzt gestartet'),
width: 160,
formatter: (v: number) => formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
width: 80,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
const hookData = {
refetch: load,
handleDelete: (id: string) => handleDelete(id),
pagination: paginationMeta,
};
if (!instanceId) {
return (
<div className={styles.adminPage}>
<p>{t('Keine Feature-Instanz gefunden')}</p>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>
{t('Workflows verwalten, ausführen und bearbeiten')}
</p>
</div>
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
{(['all', 'active', 'inactive'] as const).map((f) => (
<button
key={f}
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveFilter(f)}
disabled={loading}
>
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
</button>
))}
</div>
<button
className={styles.secondaryButton}
onClick={() => importFileInputRef.current?.click()}
disabled={importing || loading}
title={t('Workflow aus Datei importieren ({ext})', { ext: WORKFLOW_FILE_EXTENSION })}
>
<FaFileImport /> {importing ? t('Importiere...') : t('Importieren')}
</button>
<input
ref={importFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportFileSelected}
/>
<button
className={styles.secondaryButton}
onClick={() => load()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<Automation2Workflow>
data={workflows}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
{
type: 'edit',
title: t('bearbeiten'),
onAction: handleEdit,
},
{
type: 'delete',
title: t('löschen'),
},
]}
customActions={[
{
id: 'rename',
icon: <FaPen />,
title: t('umbenennen'),
onClick: (row) => handleRename(row),
},
{
id: 'activate',
icon: <FaCheck />,
title: t('aktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active === false,
},
{
id: 'deactivate',
icon: <FaBan />,
title: t('deaktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active !== false,
},
{
id: 'execute',
icon: <FaPlay />,
title: t('ausführen'),
onClick: (row) => handleExecute(row),
loading: (row) => executingId === row.id,
visible: (row) => hasManualTrigger(row),
},
{
id: 'export',
icon: <FaFileExport />,
title: t('Als Datei exportieren'),
onClick: (row) => handleExport(row),
},
]}
onDelete={(row) => handleDelete(row.id)}
hookData={hookData}
emptyMessage={t('Keine Workflows gefunden. Erstelle einen.')}
/>
</div>
<PromptDialog />
</div>
);
};