502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
/**
|
|
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
|
|
*/
|
|
|
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
|
|
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
|
import styles from './Automation2FlowEditor.module.css';
|
|
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
import { getUserDataCache } from '../../../utils/userCache';
|
|
|
|
interface CanvasHeaderProps {
|
|
workflows: Automation2Workflow[];
|
|
currentWorkflowId: string | null;
|
|
onWorkflowSelect: (workflowId: string | null) => void;
|
|
onNew: () => void;
|
|
onSave: () => void;
|
|
onExecute: () => void;
|
|
onWorkflowSettings?: () => void;
|
|
onToggleChat?: () => void;
|
|
saving: boolean;
|
|
executing: boolean;
|
|
hasNodes: boolean;
|
|
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
|
|
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
|
|
* parent can navigate the user to the first offending node. */
|
|
executeBlockedReason?: string | null;
|
|
onExecuteBlockedClick?: () => void;
|
|
executeResult: ExecuteGraphResponse | null;
|
|
versions?: AutoVersion[];
|
|
currentVersionId?: string | null;
|
|
onVersionSelect?: (versionId: string | null) => void;
|
|
onPublishVersion?: (versionId: string) => void;
|
|
onUnpublishVersion?: (versionId: string) => void;
|
|
onArchiveVersion?: (versionId: string) => void;
|
|
onCreateDraft?: () => void;
|
|
versionLoading?: boolean;
|
|
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
|
templateSaving?: boolean;
|
|
onNewFromTemplate?: () => void;
|
|
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
|
onAutoLayout?: () => void;
|
|
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
|
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
|
verboseSchema?: boolean;
|
|
onVerboseSchemaChange?: (next: boolean) => void;
|
|
}
|
|
|
|
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
|
return {
|
|
draft: { label: t('Entwurf'), color: 'var(--warning-color, #ffc107)' },
|
|
published: { label: t('Veröffentlicht'), color: 'var(--success-color, #28a745)' },
|
|
archived: { label: t('Archiviert'), color: 'var(--text-secondary, #666)' },
|
|
};
|
|
}
|
|
|
|
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|
currentWorkflowId,
|
|
onWorkflowSelect,
|
|
onNew,
|
|
onSave,
|
|
onExecute,
|
|
onWorkflowSettings,
|
|
onToggleChat,
|
|
saving,
|
|
executing,
|
|
hasNodes,
|
|
executeBlockedReason,
|
|
onExecuteBlockedClick,
|
|
executeResult,
|
|
versions,
|
|
currentVersionId,
|
|
onVersionSelect,
|
|
onPublishVersion,
|
|
onUnpublishVersion,
|
|
onArchiveVersion,
|
|
onCreateDraft,
|
|
versionLoading,
|
|
onSaveAsTemplate,
|
|
templateSaving,
|
|
onNewFromTemplate,
|
|
onWorkflowRename,
|
|
onAutoLayout,
|
|
verboseSchema,
|
|
onVerboseSchemaChange,
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
|
const statusBadge = _getStatusBadge(t);
|
|
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
|
const currentStatus = currentVersion?.status || 'draft';
|
|
const badge = statusBadge[currentStatus] || statusBadge.draft;
|
|
|
|
const [newMenuOpen, setNewMenuOpen] = useState(false);
|
|
const newMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
|
const templateMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [editingName, setEditingName] = useState(false);
|
|
const [nameValue, setNameValue] = useState('');
|
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
|
|
|
|
const _startNameEdit = useCallback(() => {
|
|
if (!currentWorkflowId || !onWorkflowRename) return;
|
|
setNameValue(currentWorkflow?.label || '');
|
|
setEditingName(true);
|
|
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
|
|
|
|
const _commitNameEdit = useCallback(() => {
|
|
setEditingName(false);
|
|
const trimmed = nameValue.trim();
|
|
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
|
|
if (trimmed !== currentWorkflow?.label) {
|
|
onWorkflowRename(currentWorkflowId, trimmed);
|
|
}
|
|
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
|
|
|
|
useEffect(() => {
|
|
if (editingName && nameInputRef.current) {
|
|
nameInputRef.current.focus();
|
|
nameInputRef.current.select();
|
|
}
|
|
}, [editingName]);
|
|
|
|
useEffect(() => {
|
|
const _handleClickOutside = (e: MouseEvent) => {
|
|
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
|
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', _handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
|
}, []);
|
|
|
|
const scopeLabels = useMemo(
|
|
() =>
|
|
({
|
|
user: t('Meine Vorlagen'),
|
|
instance: t('Instanz'),
|
|
mandate: t('Mandant'),
|
|
}) as Record<string, string>,
|
|
[t]
|
|
);
|
|
|
|
const _titleHint =
|
|
onWorkflowRename && currentWorkflow
|
|
? `${currentWorkflow.label} — ${t('Klicken zum Umbenennen')}`
|
|
: currentWorkflow?.label;
|
|
|
|
return (
|
|
<div className={styles.canvasHeader}>
|
|
<div className={styles.canvasHeaderRow}>
|
|
<div className={styles.canvasHeaderContext}>
|
|
<select
|
|
className={styles.canvasHeaderWorkflowSelect}
|
|
value={currentWorkflowId ?? ''}
|
|
onChange={(e) => {
|
|
const id = e.target.value ? e.target.value : null;
|
|
onWorkflowSelect(id);
|
|
}}
|
|
aria-label={t('Workflow laden')}
|
|
title={t('Workflow laden')}
|
|
>
|
|
<option value="">{t('Workflow laden')}</option>
|
|
{workflows.map((w) => (
|
|
<option key={w.id} value={w.id}>
|
|
{w.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className={styles.canvasHeaderTitleBlock}>
|
|
{currentWorkflowId && currentWorkflow ? (
|
|
editingName ? (
|
|
<input
|
|
ref={nameInputRef}
|
|
className={styles.canvasHeaderTitle}
|
|
value={nameValue}
|
|
onChange={(e) => setNameValue(e.target.value)}
|
|
onBlur={_commitNameEdit}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
|
|
/>
|
|
) : (
|
|
<h4
|
|
className={styles.canvasHeaderTitle}
|
|
style={{ cursor: onWorkflowRename ? 'pointer' : 'default' }}
|
|
onClick={_startNameEdit}
|
|
title={_titleHint}
|
|
>
|
|
{currentWorkflow.label}
|
|
</h4>
|
|
)
|
|
) : (
|
|
<h4 className={`${styles.canvasHeaderTitle} ${styles.canvasHeaderTitleMuted}`}>
|
|
{t('Neuer Workflow')}
|
|
</h4>
|
|
)}
|
|
</div>
|
|
{onWorkflowSettings && (
|
|
<button
|
|
type="button"
|
|
className={styles.canvasGearBtn}
|
|
title={t('Workflowkonfiguration Einstieg/Starts')}
|
|
aria-label={t('Workflow-Konfiguration')}
|
|
onClick={onWorkflowSettings}
|
|
>
|
|
<FaCog />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
|
|
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
|
|
<div className={styles.canvasHeaderSplitPair}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMain}`}
|
|
onClick={onNew}
|
|
>
|
|
{t('Neu')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMenu}`}
|
|
onClick={() => setNewMenuOpen((p) => !p)}
|
|
title={t('Neu aus Vorlage')}
|
|
aria-haspopup="menu"
|
|
aria-expanded={newMenuOpen}
|
|
>
|
|
<FaCaretDown style={{ fontSize: '0.7rem' }} />
|
|
</button>
|
|
</div>
|
|
{newMenuOpen && (
|
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
|
<button
|
|
type="button"
|
|
className={styles.canvasHeaderMenuItem}
|
|
onClick={() => { onNew(); setNewMenuOpen(false); }}
|
|
role="menuitem"
|
|
>
|
|
{t('Leerer Workflow')}
|
|
</button>
|
|
{onNewFromTemplate && (
|
|
<button
|
|
type="button"
|
|
className={styles.canvasHeaderMenuItem}
|
|
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
|
|
role="menuitem"
|
|
>
|
|
{t('Aus Vorlage…')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className={styles.retryButton}
|
|
onClick={onSave}
|
|
disabled={saving}
|
|
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
|
|
>
|
|
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
|
</button>
|
|
|
|
{onAutoLayout && (
|
|
<button
|
|
type="button"
|
|
className={styles.retryButton}
|
|
onClick={onAutoLayout}
|
|
disabled={!hasNodes}
|
|
title={t('Knoten automatisch anordnen')}
|
|
>
|
|
<FaSitemap style={{ marginRight: '0.4rem' }} />
|
|
{t('Anordnen')}
|
|
</button>
|
|
)}
|
|
|
|
{currentWorkflowId && onSaveAsTemplate && (
|
|
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
|
<button
|
|
type="button"
|
|
className={styles.retryButton}
|
|
onClick={() => setTemplateMenuOpen((p) => !p)}
|
|
disabled={templateSaving}
|
|
title={t('Als Vorlage speichern')}
|
|
aria-haspopup="menu"
|
|
aria-expanded={templateMenuOpen}
|
|
>
|
|
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
|
</button>
|
|
{templateMenuOpen && (
|
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
|
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
className={styles.canvasHeaderMenuItem}
|
|
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
|
|
role="menuitem"
|
|
>
|
|
{scopeLabels[s]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className={`${styles.retryButton} ${styles.canvasHeaderRunButton}`}
|
|
onClick={() => {
|
|
if (executeBlockedReason) {
|
|
onExecuteBlockedClick?.();
|
|
return;
|
|
}
|
|
onExecute();
|
|
}}
|
|
disabled={executing || !hasNodes}
|
|
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
|
|
title={executeBlockedReason ?? undefined}
|
|
style={
|
|
executeBlockedReason
|
|
? {
|
|
background: 'rgba(220,53,69,0.10)',
|
|
borderColor: 'var(--danger-color, #dc3545)',
|
|
color: 'var(--danger-color, #dc3545)',
|
|
cursor: 'help',
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{executing ? (
|
|
<>
|
|
<FaSpinner className={styles.spinner} style={{ flexShrink: 0 }} />
|
|
{t('Ausführen…')}
|
|
</>
|
|
) : executeBlockedReason ? (
|
|
<>
|
|
<FaPlay style={{ opacity: 0.5, flexShrink: 0 }} />
|
|
{t('Pflicht-Felder fehlen')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<FaPlay style={{ flexShrink: 0 }} />
|
|
{t('Ausführen')}
|
|
</>
|
|
)}
|
|
</button>
|
|
{onToggleChat && (
|
|
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
|
|
<FaDatabase style={{ marginRight: '0.4rem' }} />
|
|
{t('Workspace')}
|
|
</button>
|
|
)}
|
|
{_isSysAdmin && onVerboseSchemaChange && (
|
|
<label
|
|
className={styles.canvasHeaderSysadmin}
|
|
title={t('Sysadmin-Ansicht: zeigt im Node-Panel das statische Typ-Schema (Eingabe/Ausgabe) und Parameter-Typ-Badges.')}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!verboseSchema}
|
|
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
|
|
style={{ margin: 0 }}
|
|
/>
|
|
{t('Schema-Details')}
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{currentWorkflowId && versions && versions.length > 0 && (
|
|
<div className={styles.canvasHeaderVersionRow}>
|
|
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
|
|
<select
|
|
className={styles.canvasHeaderVersionSelect}
|
|
value={currentVersionId ?? ''}
|
|
onChange={(e) => onVersionSelect?.(e.target.value || null)}
|
|
disabled={versionLoading}
|
|
aria-label={t('Version')}
|
|
>
|
|
<option value="">{t('Aktuelle')}</option>
|
|
{versions.map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
v{v.versionNumber} ({statusBadge[v.status]?.label ?? v.status})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span
|
|
style={{
|
|
padding: '2px 8px',
|
|
borderRadius: 10,
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
background: badge.color + '22',
|
|
color: badge.color,
|
|
}}
|
|
>
|
|
{badge.label}
|
|
</span>
|
|
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
|
<button
|
|
type="button"
|
|
className={styles.retryButton}
|
|
onClick={() => onPublishVersion(currentVersion.id)}
|
|
disabled={versionLoading}
|
|
title={t('Version veröffentlichen')}
|
|
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
>
|
|
<FaCloudUploadAlt style={{ marginRight: 4 }} />
|
|
{t('Veröffentlichen')}
|
|
</button>
|
|
)}
|
|
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
|
|
<button
|
|
type="button"
|
|
className={styles.retryButton}
|
|
onClick={() => onUnpublishVersion(currentVersion.id)}
|
|
disabled={versionLoading}
|
|
title={t('Veröffentlichung zurücknehmen')}
|
|
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
>
|
|
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
|
|
{t('Veröffentlichung aufheben')}
|
|
</button>
|
|
)}
|
|
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
|
|
<button
|
|
type="button"
|
|
className={styles.retryButton}
|
|
onClick={() => onArchiveVersion(currentVersion.id)}
|
|
disabled={versionLoading}
|
|
title={t('Version archivieren')}
|
|
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
>
|
|
<FaArchive style={{ marginRight: 4 }} />
|
|
Archiv
|
|
</button>
|
|
)}
|
|
{onCreateDraft && (
|
|
<button
|
|
type="button"
|
|
className={styles.retryButton}
|
|
onClick={onCreateDraft}
|
|
disabled={versionLoading}
|
|
title={t('Neuen Entwurf erstellen')}
|
|
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
>
|
|
+ Entwurf
|
|
</button>
|
|
)}
|
|
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
|
|
</div>
|
|
)}
|
|
|
|
{executeResult && (
|
|
<div
|
|
style={{
|
|
marginTop: '0.5rem',
|
|
padding: '0.5rem',
|
|
borderRadius: 6,
|
|
fontSize: '0.875rem',
|
|
background: executeResult.success
|
|
? executeResult.warning
|
|
? 'rgba(255,193,7,0.15)'
|
|
: 'rgba(40,167,69,0.15)'
|
|
: (executeResult as { paused?: boolean }).paused
|
|
? 'rgba(0,123,255,0.15)'
|
|
: 'rgba(220,53,69,0.15)',
|
|
color: executeResult.success
|
|
? executeResult.warning
|
|
? 'var(--warning-color,#ffc107)'
|
|
: 'var(--success-color,#28a745)'
|
|
: (executeResult as { paused?: boolean }).paused
|
|
? 'var(--primary-color,#007bff)'
|
|
: 'var(--danger-color,#dc3545)',
|
|
}}
|
|
>
|
|
{executeResult.success ? (
|
|
executeResult.warning ? (
|
|
<>⚠ {executeResult.warning}</>
|
|
) : (
|
|
<>{t('Ausführung abgeschlossen')}</>
|
|
)
|
|
) : (executeResult as { paused?: boolean }).paused ? (
|
|
<>
|
|
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
|
Task zu bearbeiten.
|
|
</>
|
|
) : (
|
|
<>✗ {executeResult.error ?? t('Unbekannter Fehler')}</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|