ui-nyla/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx
2026-05-14 11:15:16 +02:00

944 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
};