ui-nyla/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx

1031 lines
35 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,
loadClickupListTasksForDropdown,
type Automation2Task,
type Automation2Workflow,
type CompletedRun,
type ApiRequestFunction,
} 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 { normalizeFormFieldOptions } from '../../../components/FlowEditor/nodes/form';
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 — POST /api/workflows/{instanceId}/execute with collected inputs.
* (manual first, then form or api).
*/
function getPrimaryEntryPoint(wf: Automation2Workflow) {
const invs = wf.invocations || [];
return (
invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api'))
);
}
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 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);
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 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>
</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;
}
/** Check if file matches accept string (e.g. ".pdf,image/*"). */
function relationshipTaskIdFromFormValue(v: unknown): string {
if (v && typeof v === 'object' && !Array.isArray(v) && 'add' in v) {
const a = (v as { add?: unknown[] }).add;
if (Array.isArray(a) && a[0] != null && String(a[0]).trim()) return String(a[0]);
}
return '';
}
function InputFormClickupTaskField({
connectionId,
listId,
value,
onChange,
request,
}: {
connectionId: string;
listId: string;
value: unknown;
onChange: (v: unknown) => void;
request: ApiRequestFunction;
}) {
const { t } = useLanguage();
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const cid = connectionId.trim();
const lid = listId.trim();
if (!cid || !lid) {
setTasks([]);
return;
}
let cancelled = false;
setLoading(true);
setErr(null);
loadClickupListTasksForDropdown(request, cid, lid)
.then((rows) => {
if (!cancelled) setTasks(rows);
})
.catch(() => {
if (!cancelled) {
setTasks([]);
setErr(t('Aufgaben konnten nicht geladen werden.'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [request, connectionId, listId]);
const sel = relationshipTaskIdFromFormValue(value);
if (!connectionId.trim() || !listId.trim()) {
return (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
{t('Für dieses Feld sind im Formular-Node ClickUp-Verbindung und Listen-ID gesetzt — bitte Workflow prüfen.')}
</p>
);
}
return (
<>
{err ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>{t('lade Aufgaben')}</p>
) : (
<select
value={sel}
onChange={(e) => {
const tid = e.target.value;
if (!tid) onChange({ add: [], rem: [] });
else onChange({ add: [tid], rem: [] });
}}
>
<option value="">{t('Aufgabe wählen')}</option>
{tasks.map((taskRow) => (
<option key={taskRow.id} value={taskRow.id}>
{taskRow.name}
</option>
))}
</select>
)}
</>
);
}
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);
useEffect(() => {
setUploadedFiles([]);
setUploadError(null);
}, [task.id]);
const renderInput = () => {
if (readOnly) return null;
switch (nodeType) {
case 'input.form': {
const fields =
(config.fields as Array<{
name: string;
type: string;
label: string;
required?: boolean;
options?: unknown;
clickupConnectionId?: string;
clickupListId?: string;
clickupStatusOptions?: Array<{ value: string; label: string }>;
}>) ?? [];
const requiredFields = fields.filter((f) => f.required);
const allRequiredFilled = requiredFields.every((f) => {
const v = formData[f.name];
if (f.type === 'boolean') return true;
if (f.type === 'clickup_tasks') {
return relationshipTaskIdFromFormValue(v) !== '';
}
if (f.type === 'clickup_status') {
return v !== undefined && v !== null && String(v).trim() !== '';
}
if ((f.type === 'select' || f.type === 'enum') && normalizeFormFieldOptions(f.options).some((o) => String(o.value).trim() !== '')) {
return v !== undefined && v !== null && String(v).trim() !== '';
}
return v !== undefined && v !== null && String(v).trim() !== '';
});
const renderFormControl = (
field: (typeof fields)[number],
): React.ReactNode => {
const selectChoices = normalizeFormFieldOptions(field.options).filter(
(o) => String(o.value).trim() !== '',
);
if (field.type === 'boolean') {
return (
<input
type="checkbox"
checked={(formData[field.name] as boolean) ?? false}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.checked }))
}
/>
);
}
if (field.type === 'clickup_tasks' && request) {
return (
<InputFormClickupTaskField
connectionId={field.clickupConnectionId ?? ''}
listId={field.clickupListId ?? ''}
value={formData[field.name]}
onChange={(v) => setFormData((p) => ({ ...p, [field.name]: v }))}
request={request}
/>
);
}
if (
field.type === 'clickup_status' &&
Array.isArray(field.clickupStatusOptions) &&
field.clickupStatusOptions.length > 0
) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
>
<option value="">{t('Status wählen')}</option>
{field.clickupStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
if ((field.type === 'select' || field.type === 'enum') && selectChoices.length > 0) {
return (
<select
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
>
<option value="">{t('Bitte wählen')}</option>
{selectChoices.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select>
);
}
return (
<input
type={
field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'
}
value={(formData[field.name] as string) ?? ''}
onChange={(e) =>
setFormData((p) => ({ ...p, [field.name]: e.target.value }))
}
/>
);
};
const formContent = (
<div className={styles.formFields}>
{fields.map((f) => (
<div key={f.name}>
<label>
{f.label || f.name}
{f.required && ' *'}
</label>
{renderFormControl(f)}
</div>
))}
</div>
);
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 || !allRequiredFilled}
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>
);
};