944 lines
33 KiB
TypeScript
944 lines
33 KiB
TypeScript
/**
|
||
* GraphicalEditorWorkflowsTasksPage
|
||
* Tasks only (no workflow grouping).
|
||
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
|
||
* Each task shows workflow, created, due, step, type, and action.
|
||
* Right column: active workflows with manual or form entry point — start via execute (same as Workflows page).
|
||
*/
|
||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaTimes, FaUpload } from 'react-icons/fa';
|
||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||
import { useApiRequest } from '../../../hooks/useApi';
|
||
import {
|
||
fetchTasks,
|
||
cancelPendingTaskStopRun,
|
||
completeTask,
|
||
fetchCompletedRuns,
|
||
fetchWorkflows,
|
||
executeGraph,
|
||
type Automation2Task,
|
||
type Automation2Workflow,
|
||
type CompletedRun,
|
||
} from '../../../api/workflowApi';
|
||
import { useToast } from '../../../contexts/ToastContext';
|
||
import { Popup } from '../../../components/UiComponents/Popup';
|
||
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
|
||
import { useFileOperations } from '../../../hooks/useFiles';
|
||
import styles from './Automation2WorkflowsTasks.module.css';
|
||
import {
|
||
WorkflowRuntimeFormFields,
|
||
useWorkflowRuntimeFormRequiredOk,
|
||
type WorkflowRuntimeFormFieldRow,
|
||
} from '../../../components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields';
|
||
|
||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||
|
||
function _nodeTypeLabel(nodeType: string, t: (k: string) => string): string {
|
||
switch (nodeType) {
|
||
case 'input.form': return t('Formular');
|
||
case 'input.approval': return t('Genehmigung');
|
||
case 'input.upload': return t('Upload');
|
||
case 'input.comment': return t('Kommentar');
|
||
case 'input.review': return t('Prüfung');
|
||
case 'input.selection': return t('Auswahl');
|
||
case 'input.confirmation': return t('Bestätigung');
|
||
default: return nodeType;
|
||
}
|
||
}
|
||
|
||
function formatTimestamp(ts?: number): string {
|
||
if (ts == null || ts <= 0) return '—';
|
||
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
|
||
return d.toLocaleString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function getNodeStepLabel(config: Record<string, unknown>): string {
|
||
const title = config?.title;
|
||
if (typeof title === 'string' && title.trim()) return title;
|
||
const label = config?.label;
|
||
if (typeof label === 'string' && label.trim()) return label;
|
||
if (typeof label === 'object' && label != null && 'de' in (label as Record<string, string>)) {
|
||
return (label as Record<string, string>).de ?? (label as Record<string, string>).en ?? '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** Active workflow with at least one enabled manual or form start (same idea as Tasks / editor on-demand). */
|
||
function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
|
||
const invs = wf.invocations || [];
|
||
return invs.some(
|
||
(i) => i.enabled !== false && (i.kind === 'manual' || i.kind === 'form')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Primary entry for execute — align with first start node in graph order (backend-driven),
|
||
* then fall back to manual / form / api on invocations list.
|
||
*/
|
||
function getPrimaryEntryPoint(wf: Automation2Workflow) {
|
||
const invs = wf.invocations || [];
|
||
const nodes = wf.graph?.nodes ?? [];
|
||
for (const n of nodes) {
|
||
const nodeType = n.type;
|
||
if (typeof nodeType === 'string' && nodeType.startsWith('trigger.')) {
|
||
const inv = invs.find((i) => i.enabled !== false && i.id === n.id);
|
||
if (inv) return inv;
|
||
}
|
||
}
|
||
return (
|
||
invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
|
||
invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api'))
|
||
);
|
||
}
|
||
|
||
/** Form field rows from graph trigger.form for workflow list (parameters.formFields). */
|
||
function getTriggerFormFieldsForWorkflow(wf: Automation2Workflow): WorkflowRuntimeFormFieldRow[] {
|
||
const primary = getPrimaryEntryPoint(wf);
|
||
if (!primary || primary.kind !== 'form') return [];
|
||
const nodes = wf.graph?.nodes ?? [];
|
||
let node = nodes.find((n) => n.id === primary.id && n.type === 'trigger.form');
|
||
if (!node) node = nodes.find((n) => n.type === 'trigger.form');
|
||
if (!node) return [];
|
||
const raw = (node.parameters as Record<string, unknown> | undefined)?.formFields;
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw as WorkflowRuntimeFormFieldRow[];
|
||
}
|
||
|
||
function primaryKindLabel(kind: string): string {
|
||
if (kind === 'form') return 'Formular';
|
||
if (kind === 'manual') return 'Manuell';
|
||
return kind;
|
||
}
|
||
|
||
export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
|
||
const { t } = useLanguage();
|
||
|
||
const instanceId = useInstanceId();
|
||
const { request } = useApiRequest();
|
||
const { showSuccess, showError } = useToast();
|
||
const [tasks, setTasks] = useState<Automation2Task[]>([]);
|
||
const [completedRuns, setCompletedRuns] = useState<CompletedRun[]>([]);
|
||
const [startableWorkflows, setStartableWorkflows] = useState<Automation2Workflow[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [completedExpanded, setCompletedExpanded] = useState(false);
|
||
const [outputExpanded, setOutputExpanded] = useState(true);
|
||
const [submitting, setSubmitting] = useState<string | null>(null);
|
||
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
|
||
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
|
||
const [formStartWorkflow, setFormStartWorkflow] = useState<Automation2Workflow | null>(null);
|
||
const [formStartFields, setFormStartFields] = useState<WorkflowRuntimeFormFieldRow[]>([]);
|
||
const [startFormData, setStartFormData] = useState<Record<string, unknown>>({});
|
||
|
||
const load = useCallback(async () => {
|
||
if (!instanceId) return;
|
||
setLoading(true);
|
||
try {
|
||
const [taskList, runs] = await Promise.all([
|
||
fetchTasks(request, instanceId),
|
||
fetchCompletedRuns(request, instanceId, 20),
|
||
]);
|
||
setTasks(taskList);
|
||
setCompletedRuns(runs);
|
||
try {
|
||
const activeWfs = await fetchWorkflows(request, instanceId, { active: true });
|
||
const list: Automation2Workflow[] = Array.isArray(activeWfs)
|
||
? activeWfs
|
||
: (activeWfs && typeof activeWfs === 'object' && 'items' in activeWfs && Array.isArray((activeWfs as { items: Automation2Workflow[] }).items)
|
||
? (activeWfs as { items: Automation2Workflow[] }).items
|
||
: []);
|
||
setStartableWorkflows(
|
||
list.filter(
|
||
(w) => w.active !== false && hasManualOrFormInvocation(w)
|
||
)
|
||
);
|
||
} catch (we) {
|
||
console.error('[graphicalEditor] load startable workflows failed', we);
|
||
setStartableWorkflows([]);
|
||
}
|
||
} catch (e) {
|
||
console.error('[graphicalEditor] load failed', e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [instanceId, request]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
const handleComplete = async (taskId: string, result: Record<string, unknown>) => {
|
||
if (!instanceId) return;
|
||
setSubmitting(taskId);
|
||
try {
|
||
await completeTask(request, instanceId, taskId, result);
|
||
await load();
|
||
} catch (e) {
|
||
console.error('[graphicalEditor] complete failed', e);
|
||
} finally {
|
||
setSubmitting(null);
|
||
}
|
||
};
|
||
|
||
const handleDismissOpenTask = async (taskId: string) => {
|
||
if (!instanceId) return;
|
||
setDismissingTaskId(taskId);
|
||
try {
|
||
const res = await cancelPendingTaskStopRun(request, instanceId, taskId);
|
||
if (res.success) {
|
||
showSuccess(t('Ausführung abgebrochen'));
|
||
await load();
|
||
} else {
|
||
showError(t('Abbrechen fehlgeschlagen'));
|
||
}
|
||
} catch (e: unknown) {
|
||
const msg =
|
||
(e as { message?: string })?.message ?? t('Abbrechen fehlgeschlagen');
|
||
showError(msg);
|
||
console.error('[graphicalEditor] cancel task failed', e);
|
||
} finally {
|
||
setDismissingTaskId(null);
|
||
}
|
||
};
|
||
|
||
const handleStartWorkflow = useCallback(
|
||
async (wf: Automation2Workflow) => {
|
||
if (!instanceId || !wf.graph) return;
|
||
const primary = getPrimaryEntryPoint(wf);
|
||
if (primary?.kind === 'form') {
|
||
setFormStartFields(getTriggerFormFieldsForWorkflow(wf));
|
||
setStartFormData({});
|
||
setFormStartWorkflow(wf);
|
||
return;
|
||
}
|
||
setExecutingWorkflowId(wf.id);
|
||
try {
|
||
const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
|
||
...(primary ? { entryPointId: primary.id } : {}),
|
||
});
|
||
if (result?.success) {
|
||
if (result?.paused) {
|
||
showSuccess(t('Workflow gestartet und bei Human Task pausiert.'));
|
||
} else {
|
||
showSuccess(t('Workflow gestartet'));
|
||
}
|
||
await load();
|
||
} else {
|
||
showError(result?.error || t('Ausführung fehlgeschlagen'));
|
||
}
|
||
} catch (e: unknown) {
|
||
const msg =
|
||
(e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen');
|
||
showError(msg);
|
||
} finally {
|
||
setExecutingWorkflowId(null);
|
||
}
|
||
},
|
||
[instanceId, request, showSuccess, showError, load, t]
|
||
);
|
||
|
||
const formStartRequiredOk = useWorkflowRuntimeFormRequiredOk(formStartFields, startFormData);
|
||
|
||
const handleFormStartSubmit = useCallback(async () => {
|
||
if (!instanceId || !formStartWorkflow?.graph) return;
|
||
const wf = formStartWorkflow;
|
||
const primary = getPrimaryEntryPoint(wf);
|
||
const payload = { ...startFormData };
|
||
setExecutingWorkflowId(wf.id);
|
||
try {
|
||
const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
|
||
...(primary ? { entryPointId: primary.id } : {}),
|
||
payload,
|
||
});
|
||
if (result?.success) {
|
||
if (result?.paused) {
|
||
showSuccess(t('Workflow gestartet und bei Human Task pausiert.'));
|
||
} else {
|
||
showSuccess(t('Workflow gestartet'));
|
||
}
|
||
await load();
|
||
} else {
|
||
showError(result?.error || t('Ausführung fehlgeschlagen'));
|
||
}
|
||
} catch (e: unknown) {
|
||
const msg =
|
||
(e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen');
|
||
showError(msg);
|
||
} finally {
|
||
setExecutingWorkflowId(null);
|
||
setFormStartWorkflow(null);
|
||
}
|
||
}, [
|
||
instanceId,
|
||
formStartWorkflow,
|
||
startFormData,
|
||
request,
|
||
showSuccess,
|
||
showError,
|
||
load,
|
||
t,
|
||
]);
|
||
|
||
const openTasks = tasks.filter((task) => task.status === 'pending');
|
||
const completedTasks = tasks.filter((task) => task.status !== 'pending');
|
||
|
||
if (!instanceId) {
|
||
return (
|
||
<div className={styles.placeholder}>
|
||
<p>{t('keine Featureinstanz gefunden')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={styles.loading}>
|
||
<FaSpinner className={styles.spinner} />
|
||
<p>{t('lade Tasks')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={styles.pageLayout}>
|
||
<div className={styles.mainColumn}>
|
||
<div className={styles.container}>
|
||
{/* Open tasks */}
|
||
<section className={styles.section}>
|
||
<h3 className={styles.sectionTitle}>
|
||
{t('Offene Tasks')}
|
||
{openTasks.length > 0 && <span className={styles.badge}>{openTasks.length}</span>}
|
||
</h3>
|
||
{openTasks.length === 0 ? (
|
||
<p className={styles.empty}>{t('keine offenen Tasks')}</p>
|
||
) : (
|
||
<div className={styles.taskList}>
|
||
{openTasks.map((task) => (
|
||
<TaskCard
|
||
key={task.id}
|
||
task={task}
|
||
instanceId={instanceId ?? undefined}
|
||
onSubmit={(result) => handleComplete(task.id, result)}
|
||
submitting={submitting === task.id}
|
||
showDismiss
|
||
onDismiss={() => handleDismissOpenTask(task.id)}
|
||
dismissing={dismissingTaskId === task.id}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Completed tasks */}
|
||
<section className={styles.section}>
|
||
<button
|
||
type="button"
|
||
className={styles.completedHeader}
|
||
onClick={() => setCompletedExpanded((p) => !p)}
|
||
>
|
||
{completedExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
||
<span>{t('erledigte Tasks')}</span>
|
||
{completedTasks.length > 0 && (
|
||
<span className={styles.badge}>{completedTasks.length}</span>
|
||
)}
|
||
</button>
|
||
{completedExpanded && (
|
||
<div className={styles.completedList}>
|
||
{completedTasks.length === 0 ? (
|
||
<p className={styles.empty}>{t('keine erledigten Tasks')}</p>
|
||
) : (
|
||
completedTasks.map((task) => (
|
||
<TaskCard
|
||
key={task.id}
|
||
task={task}
|
||
instanceId={instanceId ?? undefined}
|
||
onSubmit={(result) => handleComplete(task.id, result)}
|
||
submitting={submitting === task.id}
|
||
readOnly
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Output – abgeschlossene Workflows mit Ergebnis */}
|
||
<section className={styles.section}>
|
||
<button
|
||
type="button"
|
||
className={styles.completedHeader}
|
||
onClick={() => setOutputExpanded((p) => !p)}
|
||
>
|
||
{outputExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
||
<span>{t('Resultate')}</span>
|
||
{completedRuns.length > 0 && (
|
||
<span className={styles.badge}>{completedRuns.length}</span>
|
||
)}
|
||
</button>
|
||
{outputExpanded && (
|
||
<div className={styles.completedList}>
|
||
{completedRuns.length === 0 ? (
|
||
<p className={styles.empty}>
|
||
{t('Keine abgeschlossenen Workflows. Führen Sie einen Workflow aus (z.B. im Editor), um hier die Ergebnisse zu sehen.')}
|
||
</p>
|
||
) : (
|
||
completedRuns.map((run) => (
|
||
<OutputCard key={run.id} run={run} instanceId={instanceId ?? undefined} />
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<aside className={styles.startSidebar} aria-label={t('Workflows starten')}>
|
||
<h3 className={styles.startSidebarTitle}>{t('Workflow starten')}</h3>
|
||
<div className={styles.startSidebarList}>
|
||
{startableWorkflows.length === 0 ? (
|
||
<p className={styles.empty}>
|
||
{t('Keine aktiven Workflows mit manuellem oder Formular-Start.')}
|
||
</p>
|
||
) : (
|
||
startableWorkflows.map((wf) => {
|
||
const primary = getPrimaryEntryPoint(wf);
|
||
const kind = primary?.kind ?? 'manual';
|
||
return (
|
||
<div key={wf.id} className={styles.startWorkflowRow}>
|
||
<div className={styles.startWorkflowInfo}>
|
||
<span className={styles.startWorkflowName} title={wf.label}>
|
||
{wf.label || wf.id}
|
||
</span>
|
||
<span className={styles.startWorkflowKind}>
|
||
{primaryKindLabel(kind)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.startButton}
|
||
title={t('Workflow ausführen')}
|
||
disabled={executingWorkflowId === wf.id}
|
||
onClick={() => handleStartWorkflow(wf)}
|
||
>
|
||
{executingWorkflowId === wf.id ? (
|
||
<FaSpinner className={styles.spinner} />
|
||
) : (
|
||
<FaPlay />
|
||
)}
|
||
</button>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
<Popup
|
||
isOpen={formStartWorkflow != null}
|
||
title={t('Formular ausfüllen')}
|
||
onClose={() => setFormStartWorkflow(null)}
|
||
closable={
|
||
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
|
||
}
|
||
closeOnEscape={
|
||
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
|
||
}
|
||
size="medium"
|
||
footerContent={
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleFormStartSubmit()}
|
||
disabled={
|
||
!formStartRequiredOk ||
|
||
(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
|
||
}
|
||
className={styles.popupSubmitButton}
|
||
>
|
||
{formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id
|
||
? t('wird gesendet')
|
||
: t('absenden')}
|
||
</button>
|
||
}
|
||
>
|
||
<WorkflowRuntimeFormFields
|
||
fields={formStartFields}
|
||
formData={startFormData}
|
||
setFormData={setStartFormData}
|
||
formFieldsClassName={styles.formFields}
|
||
/>
|
||
</Popup>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** Output card for completed workflow runs – zeigt nur die erstellten Dateien (mit fileId). */
|
||
const OutputCard: React.FC<{
|
||
run: CompletedRun;
|
||
instanceId?: string;
|
||
}> = ({ run }) => {
|
||
const { t } = useLanguage();
|
||
const ts = run.sysModifiedAt ?? run.sysCreatedAt ?? 0;
|
||
const files: Array<{ name: string; fileId: string }> = [];
|
||
const nodeOutputs = run.nodeOutputs ?? {};
|
||
for (const [, out] of Object.entries(nodeOutputs)) {
|
||
if (!out || typeof out !== 'object') continue;
|
||
const o = out as Record<string, unknown>;
|
||
const docs = (o.documents ?? o.documentList ?? []) as Array<Record<string, unknown>>;
|
||
if (!Array.isArray(docs)) continue;
|
||
for (const d of docs) {
|
||
const fileId = (d.validationMetadata as Record<string, unknown>)?.fileId as string | undefined;
|
||
if (fileId) {
|
||
files.push({
|
||
name: String(d.documentName ?? d.fileName ?? t('Datei')),
|
||
fileId,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return (
|
||
<div className={styles.taskCard}>
|
||
<div className={styles.taskMeta}>
|
||
<div className={styles.taskMetaRow}>
|
||
<span className={styles.metaLabel}>{t('Workflow')}</span>
|
||
<span className={styles.metaValue}>{run.workflowLabel || run.workflowId || '—'}</span>
|
||
</div>
|
||
<div className={styles.taskMetaRow}>
|
||
<span className={styles.metaLabel}>{t('Abgeschlossen')}</span>
|
||
<span className={styles.metaValue}>{formatTimestamp(ts)}</span>
|
||
</div>
|
||
</div>
|
||
{files.length > 0 ? (
|
||
<div className={styles.outputContent}>
|
||
<span className={styles.metaLabel}>{t('Dateien')}</span>
|
||
<ul className={styles.uploadedList}>
|
||
{files.map((f, j) => (
|
||
<li key={j}>
|
||
<Link
|
||
to="/basedata/files"
|
||
className={styles.downloadLink}
|
||
>
|
||
{f.name}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : (
|
||
<p className={styles.empty}>{t('kein Output, z.B. Workflow ohne')}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface TaskCardProps {
|
||
task: Automation2Task;
|
||
instanceId?: string;
|
||
onSubmit: (result: Record<string, unknown>) => void;
|
||
submitting: boolean;
|
||
readOnly?: boolean;
|
||
/** Open-task card: show top-right control to cancel run and remove from list. */
|
||
showDismiss?: boolean;
|
||
onDismiss?: () => void;
|
||
dismissing?: boolean;
|
||
}
|
||
|
||
const TaskCard: React.FC<TaskCardProps> = ({
|
||
task,
|
||
instanceId,
|
||
onSubmit,
|
||
submitting,
|
||
readOnly = false,
|
||
showDismiss = false,
|
||
onDismiss,
|
||
dismissing = false,
|
||
}) => {
|
||
const { t } = useLanguage();
|
||
const { request } = useApiRequest();
|
||
const { handleFileUpload } = useFileOperations();
|
||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||
const [formPopupOpen, setFormPopupOpen] = useState(false);
|
||
const [uploadedFiles, setUploadedFiles] = useState<Array<{ id: string; fileName: string; file?: Record<string, unknown> }>>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const config = task.config ?? {};
|
||
const nodeType = task.nodeType;
|
||
const stepLabel = getNodeStepLabel(config);
|
||
|
||
const inputFormFields: WorkflowRuntimeFormFieldRow[] =
|
||
nodeType === 'input.form'
|
||
? ((config.fields as WorkflowRuntimeFormFieldRow[]) ?? [])
|
||
: [];
|
||
const inputFormRequiredOk = useWorkflowRuntimeFormRequiredOk(inputFormFields, formData);
|
||
|
||
useEffect(() => {
|
||
setUploadedFiles([]);
|
||
setUploadError(null);
|
||
}, [task.id]);
|
||
|
||
const renderInput = () => {
|
||
if (readOnly) return null;
|
||
switch (nodeType) {
|
||
case 'input.form': {
|
||
const formContent = (
|
||
<WorkflowRuntimeFormFields
|
||
fields={inputFormFields}
|
||
formData={formData}
|
||
setFormData={setFormData}
|
||
formFieldsClassName={styles.formFields}
|
||
/>
|
||
);
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFormPopupOpen(true)}
|
||
disabled={submitting}
|
||
className={styles.openFormButton}
|
||
>
|
||
Formular bearbeiten
|
||
</button>
|
||
<Popup
|
||
isOpen={formPopupOpen}
|
||
title={t('Formular ausfüllen')}
|
||
onClose={() => setFormPopupOpen(false)}
|
||
size="medium"
|
||
footerContent={
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
// Match node output shape used by refs (payload.*) and outputPreviewRegistry.input.form
|
||
onSubmit({ payload: formData });
|
||
setFormPopupOpen(false);
|
||
}}
|
||
disabled={submitting || !inputFormRequiredOk}
|
||
className={styles.popupSubmitButton}
|
||
>
|
||
{submitting ? t('wird gesendet') : t('absenden')}
|
||
</button>
|
||
}
|
||
>
|
||
{formContent}
|
||
</Popup>
|
||
</>
|
||
);
|
||
}
|
||
case 'input.approval':
|
||
return (
|
||
<div>
|
||
{config.title != null && String(config.title) !== '' && <h4>{String(config.title)}</h4>}
|
||
{config.description != null && String(config.description) !== '' && <p>{String(config.description)}</p>}
|
||
<div className={styles.approvalButtons}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit({ approved: true })}
|
||
disabled={submitting}
|
||
>
|
||
{t('Genehmigen')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit({ approved: false })}
|
||
disabled={submitting}
|
||
>
|
||
{t('Ablehnen')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
case 'input.comment':
|
||
return (
|
||
<div>
|
||
<textarea
|
||
placeholder={(config.placeholder as string) ?? t('Kommentar...')}
|
||
value={(formData.comment as string) ?? ''}
|
||
onChange={(e) => setFormData({ comment: e.target.value })}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit(formData)}
|
||
disabled={
|
||
submitting ||
|
||
((config.required !== false) && !formData.comment)
|
||
}
|
||
>
|
||
{t('Absenden')}
|
||
</button>
|
||
</div>
|
||
);
|
||
case 'input.selection': {
|
||
const options =
|
||
(config.options as Array<{ value: string; label: string }>) ?? [];
|
||
const multiple = config.multiple as boolean;
|
||
return (
|
||
<div>
|
||
{options.map((o) => (
|
||
<label key={o.value}>
|
||
<input
|
||
type={multiple ? 'checkbox' : 'radio'}
|
||
name={task.id}
|
||
value={o.value}
|
||
onChange={(e) => {
|
||
if (multiple) {
|
||
const prev = (formData.selected as string[]) ?? [];
|
||
const next = e.target.checked
|
||
? [...prev, o.value]
|
||
: prev.filter((v) => v !== o.value);
|
||
setFormData((p) => ({ ...p, selected: next }));
|
||
} else {
|
||
setFormData((p) => ({ ...p, selected: o.value }));
|
||
}
|
||
}}
|
||
/>
|
||
{o.label || o.value}
|
||
</label>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit(formData)}
|
||
disabled={submitting}
|
||
>
|
||
{t('Absenden')}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
case 'input.confirmation':
|
||
return (
|
||
<div>
|
||
<p>{(config.question as string) ?? t('Bestätigen?')}</p>
|
||
<div className={styles.approvalButtons}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit({ confirmed: true })}
|
||
disabled={submitting}
|
||
>
|
||
{typeof config.confirmLabel === 'string' ? config.confirmLabel : t('Bestätigen')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit({ confirmed: false })}
|
||
disabled={submitting}
|
||
>
|
||
{typeof config.rejectLabel === 'string' ? config.rejectLabel : t('Ablehnen')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
case 'input.upload': {
|
||
const acceptStr = getAcceptStringFromConfig(config);
|
||
const maxSizeMB = (config.maxSize as number) ?? 10;
|
||
const allowMultiple = (config.multiple as boolean) ?? false;
|
||
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files;
|
||
if (!files?.length || !instanceId) return;
|
||
if (!allowMultiple && files.length > 1) {
|
||
setUploadError('Nur eine Datei erlaubt.');
|
||
return;
|
||
}
|
||
setUploadError(null);
|
||
setUploading(true);
|
||
const results: Array<{ id: string; fileName: string; file?: Record<string, unknown> }> = [];
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i];
|
||
if (file.size > maxSizeBytes) {
|
||
setUploadError(`Datei "${file.name}" zu groß (max. ${maxSizeMB} MB).`);
|
||
setUploading(false);
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
if (acceptStr && acceptStr !== '*' && !fileMatchesAccept(file, acceptStr)) {
|
||
setUploadError(
|
||
`Die Datei „${file.name}“ hat ein nicht erlaubtes Format. Bitte eine Datei mit passender Endung verwenden (laut Upload-Schritt im Workflow).`,
|
||
);
|
||
setUploading(false);
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
try {
|
||
const result = await handleFileUpload(
|
||
file,
|
||
task.workflowId ?? undefined,
|
||
instanceId ?? undefined
|
||
);
|
||
if (result?.success && result?.fileData) {
|
||
const fileMeta = result.fileData?.file ?? result.fileData;
|
||
const fileId = fileMeta?.id ?? fileMeta?.fileName;
|
||
if (fileId) {
|
||
results.push({
|
||
id: fileId,
|
||
fileName: fileMeta?.fileName ?? file.name,
|
||
file: fileMeta,
|
||
});
|
||
}
|
||
} else if (result?.error) {
|
||
setUploadError(result.error);
|
||
setUploading(false);
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
} catch (err: unknown) {
|
||
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })?.response?.data?.detail ?? (err as Error)?.message ?? t('Upload fehlgeschlagen');
|
||
setUploadError(msg);
|
||
setUploading(false);
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
}
|
||
setUploadedFiles((prev) => (allowMultiple ? [...prev, ...results] : results));
|
||
setUploading(false);
|
||
e.target.value = '';
|
||
};
|
||
|
||
const handleSubmitUpload = () => {
|
||
if (uploadedFiles.length === 0) {
|
||
setUploadError(t('Bitte mindestens eine Datei hochladen.'));
|
||
return;
|
||
}
|
||
const file = uploadedFiles[0]?.file ?? { id: uploadedFiles[0]?.id, fileName: uploadedFiles[0]?.fileName };
|
||
const files = uploadedFiles.map((u) => u.file ?? { id: u.id, fileName: u.fileName });
|
||
const fileIds = uploadedFiles.map((u) => u.id);
|
||
onSubmit({
|
||
file,
|
||
files,
|
||
fileIds,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className={styles.uploadTaskBlock}>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept={acceptStr === '*' ? undefined : acceptStr || undefined}
|
||
multiple={allowMultiple}
|
||
onChange={handleFileSelect}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={submitting || uploading}
|
||
className={styles.uploadButton}
|
||
>
|
||
<FaUpload /> {uploading ? t('wird hochgeladen') : t('Dateien auswählen')}
|
||
</button>
|
||
{uploadError && <p className={styles.uploadError}>{uploadError}</p>}
|
||
{uploadedFiles.length > 0 && (
|
||
<ul className={styles.uploadedList}>
|
||
{uploadedFiles.map((u) => (
|
||
<li key={u.id}>{u.fileName}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={handleSubmitUpload}
|
||
disabled={submitting || uploading || uploadedFiles.length === 0}
|
||
className={styles.popupSubmitButton}
|
||
>
|
||
{submitting ? t('wird gesendet') : t('absenden')}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
case 'input.review':
|
||
return (
|
||
<div>
|
||
<p>{t('Review-Inhalt anzeigen, Feedback')}</p>
|
||
<textarea
|
||
placeholder={t('Feedback…')}
|
||
value={(formData.feedback as string) ?? ''}
|
||
onChange={(e) => setFormData({ feedback: e.target.value })}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit(formData)}
|
||
disabled={submitting}
|
||
>
|
||
{t('Absenden')}
|
||
</button>
|
||
</div>
|
||
);
|
||
default:
|
||
return (
|
||
<div>
|
||
<p>{t('Unbekannter Task-Typ: {typ}', { typ: String(nodeType) })}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSubmit({})}
|
||
disabled={submitting}
|
||
>
|
||
{t('Absenden')}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
};
|
||
|
||
const cardClass = showDismiss
|
||
? `${styles.taskCard} ${styles.taskCardDismissable}`
|
||
: styles.taskCard;
|
||
|
||
return (
|
||
<div className={cardClass}>
|
||
{showDismiss && onDismiss ? (
|
||
<button
|
||
type="button"
|
||
className={styles.dismissOpenTaskBtn}
|
||
title={t('Task entfernen und Ausführung abbrechen')}
|
||
aria-label={t('Task entfernen und Ausführung abbrechen')}
|
||
disabled={submitting || dismissing}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDismiss();
|
||
}}
|
||
>
|
||
{dismissing ? <FaSpinner className={styles.spinner} /> : <FaTimes />}
|
||
</button>
|
||
) : null}
|
||
<div className={styles.taskMeta}>
|
||
<div className={styles.taskMetaRow}>
|
||
<span className={styles.metaLabel}>{t('Workflow')}</span>
|
||
<span className={styles.metaValue}>
|
||
{task.workflowLabel || task.workflowId || '—'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.taskMetaRow}>
|
||
<span className={styles.metaLabel}>{t('erstellt')}</span>
|
||
<span className={styles.metaValue}>
|
||
{formatTimestamp(task.createdAt)}
|
||
</span>
|
||
</div>
|
||
<div className={styles.taskMetaRow}>
|
||
<span className={styles.metaLabel}>{t('fällig')}</span>
|
||
<span className={styles.metaValue}>
|
||
{formatTimestamp(task.dueAt)}
|
||
</span>
|
||
</div>
|
||
{stepLabel && (
|
||
<div className={styles.taskMetaRow}>
|
||
<span className={styles.metaLabel}>{t('Schritt')}</span>
|
||
<span className={styles.metaValue}>{stepLabel}</span>
|
||
</div>
|
||
)}
|
||
<div className={styles.taskMetaRow}>
|
||
<span className={styles.metaLabel}>{t('Typ')}</span>
|
||
<span className={styles.metaValue}>
|
||
{_nodeTypeLabel(nodeType, t)}
|
||
</span>
|
||
</div>
|
||
|
||
</div>
|
||
{renderInput()}
|
||
</div>
|
||
);
|
||
};
|