ui-nyla/src/components/FlowEditor/editor/CanvasHeader.tsx
2026-05-20 17:52:31 +02:00

431 lines
15 KiB
TypeScript

/**
* CanvasHeader - Workflow controls, version selector, and execute result.
*/
import React, { useState, useRef, useEffect, useMemo } from 'react';
import {
FaPlay,
FaSpinner,
FaCloudUploadAlt,
FaCloudDownloadAlt,
FaArchive,
FaBookmark,
FaCaretDown,
FaSave,
FaPlus,
FaChevronLeft,
FaChevronRight,
} 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';
import { Button } from '../../UiComponents/Button';
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onToggleWorkspacePanel?: () => void;
workspacePanelOpen?: boolean;
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;
/** 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)' },
};
}
const _tb = 'secondary' as const;
const _ts = 'sm' as const;
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onToggleWorkspacePanel,
workspacePanelOpen,
saving,
executing,
hasNodes,
executeBlockedReason,
onExecuteBlockedClick,
executeResult,
versions,
currentVersionId,
onVersionSelect,
onPublishVersion,
onUnpublishVersion,
onArchiveVersion,
onCreateDraft,
versionLoading,
onSaveAsTemplate,
templateSaving,
onNewFromTemplate,
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);
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 _panelOpen = workspacePanelOpen ?? false;
const _runAriaLabel = executing
? t('Ausführen…')
: executeBlockedReason
? t('Pflicht-Felder fehlen')
: t('Ausführen');
const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
return (
<div className={styles.canvasHeader}>
<div className={styles.canvasHeaderRow}>
<div
className={styles.canvasHeaderToolbar}
role="toolbar"
aria-label={t('Workflow-Aktionen')}
>
{onToggleWorkspacePanel && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={_panelOpen ? FaChevronLeft : FaChevronRight}
className={styles.canvasHeaderIconBtn}
onClick={onToggleWorkspacePanel}
title={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
/>
)}
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaPlus}
className={`${styles.canvasHeaderIconBtn} ${onNewFromTemplate ? styles.canvasHeaderNewSplitMain : ''}`}
onClick={onNew}
title={t('Neuer leerer Workflow')}
aria-label={t('Neuer leerer Workflow')}
/>
{onNewFromTemplate && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaCaretDown}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Aus Vorlage…')}
aria-label={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
/>
)}
</div>
{newMenuOpen && onNewFromTemplate && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => {
onNewFromTemplate();
setNewMenuOpen(false);
}}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
</div>
)}
</div>
<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>
<Button
type="button"
variant={_tb}
size={_ts}
icon={saving ? undefined : FaSave}
className={styles.canvasHeaderIconBtn}
loading={saving}
disabled={saving}
onClick={onSave}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
aria-label={t('Speichern')}
/>
<Button
type="button"
variant={_tb}
size={_ts}
icon={executing ? undefined : FaPlay}
loading={executing}
disabled={executing || !hasNodes}
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
aria-label={_runAriaLabel}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={_runTitle}
/>
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaBookmark}
loading={templateSaving}
disabled={templateSaving}
onClick={() => setTemplateMenuOpen((p) => !p)}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
{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>
)}
{_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>
);
};