350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
/**
|
|
* GraphicalEditorTemplatesPage
|
|
*
|
|
* Template management with scope tabs (Meine / Instanz / Mandant / System).
|
|
* Uses FormGeneratorTable for the data list.
|
|
* Actions: Copy to my workflows, Share (scope upgrade), Delete.
|
|
*/
|
|
|
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { FaCopy, FaSync, FaShareAlt, FaPen } from 'react-icons/fa';
|
|
import { usePrompt } from '../../../hooks/usePrompt';
|
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
|
import { useApiRequest } from '../../../hooks/useApi';
|
|
import {
|
|
fetchTemplates,
|
|
copyTemplate,
|
|
shareTemplate,
|
|
deleteWorkflow,
|
|
updateWorkflow,
|
|
type AutoWorkflowTemplate,
|
|
type AutoTemplateScope,
|
|
} from '../../../api/workflowApi';
|
|
import { fetchAttributes } from '../../../api/attributesApi';
|
|
import type { AttributeDefinition } from '../../../api/attributesApi';
|
|
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
|
import { useToast } from '../../../contexts/ToastContext';
|
|
import { formatUnixTimestamp } from '../../../utils/time';
|
|
import styles from '../../../pages/admin/Admin.module.css';
|
|
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
|
|
function _formatTs(ts?: number): string {
|
|
if (ts == null || ts <= 0) return '—';
|
|
const sec = ts < 1e12 ? ts : ts / 1000;
|
|
const { time } = formatUnixTimestamp(sec, undefined, {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
return time;
|
|
}
|
|
|
|
export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
|
|
const scopeLabels = useMemo(
|
|
(): Record<AutoTemplateScope, string> => ({
|
|
user: t('Meine'),
|
|
instance: t('Instanz'),
|
|
mandate: t('Mandant'),
|
|
system: t('System'),
|
|
}),
|
|
[t],
|
|
);
|
|
|
|
const instanceId = useInstanceId();
|
|
const { mandateId } = useParams<{ mandateId: string }>();
|
|
const { request } = useApiRequest();
|
|
const navigate = useNavigate();
|
|
const { showSuccess, showError } = useToast();
|
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
|
|
|
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
|
|
const [copyingId, setCopyingId] = useState<string | null>(null);
|
|
const [sharingId, setSharingId] = useState<string | null>(null);
|
|
|
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
|
|
|
useEffect(() => {
|
|
fetchAttributes(request, 'Automation2WorkflowView')
|
|
.then(setBackendAttributes)
|
|
.catch((err) => {
|
|
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
|
|
});
|
|
}, [request]);
|
|
|
|
const load = useCallback(async (paginationParams?: any) => {
|
|
if (!instanceId) return;
|
|
setLoading(true);
|
|
try {
|
|
const scope = activeScope === 'all' ? undefined : activeScope;
|
|
const result = await fetchTemplates(request, instanceId, scope, paginationParams);
|
|
if (result && typeof result === 'object' && 'items' in result) {
|
|
setTemplates(result.items as AutoWorkflowTemplate[]);
|
|
setPaginationMeta(result.pagination);
|
|
} else {
|
|
setTemplates(result as AutoWorkflowTemplate[]);
|
|
setPaginationMeta(null);
|
|
}
|
|
} catch (e) {
|
|
console.error('[graphicalEditor] load templates failed', e);
|
|
showError(t('Fehler beim Laden der Vorlagen'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [instanceId, request, showError, activeScope, t]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const handleDelete = useCallback(
|
|
async (templateId: string): Promise<boolean> => {
|
|
if (!instanceId) return false;
|
|
try {
|
|
await deleteWorkflow(request, instanceId, templateId);
|
|
showSuccess(t('Vorlage gelöscht'));
|
|
await load();
|
|
return true;
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
|
|
return false;
|
|
}
|
|
},
|
|
[instanceId, request, showSuccess, showError, load, t]
|
|
);
|
|
|
|
const handleCopy = useCallback(
|
|
async (row: AutoWorkflowTemplate) => {
|
|
if (!instanceId) return;
|
|
setCopyingId(row.id);
|
|
try {
|
|
await copyTemplate(request, instanceId, row.id);
|
|
showSuccess(t('Vorlage als Workflow kopiert'));
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Kopieren fehlgeschlagen') }));
|
|
} finally {
|
|
setCopyingId(null);
|
|
}
|
|
},
|
|
[instanceId, request, showSuccess, showError, t]
|
|
);
|
|
|
|
const handleShare = useCallback(
|
|
async (row: AutoWorkflowTemplate, targetScope: AutoTemplateScope) => {
|
|
if (!instanceId) return;
|
|
setSharingId(row.id);
|
|
try {
|
|
await shareTemplate(request, instanceId, row.id, targetScope);
|
|
showSuccess(t('Scope geändert: {scope}', { scope: scopeLabels[targetScope] }));
|
|
await load();
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Scope-Änderung fehlgeschlagen') }));
|
|
} finally {
|
|
setSharingId(null);
|
|
}
|
|
},
|
|
[instanceId, request, showSuccess, showError, load, scopeLabels, t]
|
|
);
|
|
|
|
const [scopeMenuId, setScopeMenuId] = useState<string | null>(null);
|
|
|
|
const handleRename = useCallback(
|
|
async (row: AutoWorkflowTemplate) => {
|
|
if (!instanceId) return;
|
|
const newLabel = await promptInput(t('Neuer Name:'), {
|
|
title: t('Vorlage umbenennen'),
|
|
defaultValue: row.label,
|
|
placeholder: t('Vorlagen-Name'),
|
|
});
|
|
if (!newLabel || newLabel.trim() === row.label) return;
|
|
try {
|
|
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
|
|
showSuccess(t('Vorlage umbenannt'));
|
|
await load();
|
|
} catch (e: any) {
|
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
|
|
}
|
|
},
|
|
[instanceId, request, promptInput, showSuccess, showError, load, t]
|
|
);
|
|
|
|
const handleEdit = useCallback(
|
|
(row: AutoWorkflowTemplate) => {
|
|
if (!mandateId || !instanceId) return;
|
|
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
|
|
},
|
|
[mandateId, instanceId, navigate]
|
|
);
|
|
|
|
const _rawColumns: ColumnConfig[] = useMemo(
|
|
() => [
|
|
{ key: 'label', label: t('Vorlage'), width: 220, sortable: true, filterable: true },
|
|
{ key: 'templateScope', width: 100, sortable: true, filterable: true },
|
|
{ key: 'sharedReadOnly', width: 100, sortable: true, filterable: true },
|
|
{ key: 'sysCreatedBy', width: 140, sortable: true, filterable: true, displayField: 'sysCreatedByLabel' },
|
|
{ key: 'sysCreatedAt', width: 140, sortable: true, filterable: true, formatter: (v: number) => _formatTs(v) },
|
|
],
|
|
[t],
|
|
);
|
|
|
|
const columns = useMemo(
|
|
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
|
[_rawColumns, backendAttributes],
|
|
);
|
|
|
|
if (!instanceId) {
|
|
return (
|
|
<div className={styles.adminPage}>
|
|
<p>{t('Keine Feature-Instanz gefunden')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
|
<div className={styles.pageHeader}>
|
|
<div>
|
|
<p className={styles.pageSubtitle}>
|
|
{t('Vorlagen verwalten, kopieren und freigeben')}
|
|
</p>
|
|
</div>
|
|
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
|
|
<button
|
|
key={s}
|
|
className={activeScope === s ? styles.primaryButton : styles.secondaryButton}
|
|
onClick={() => setActiveScope(s)}
|
|
disabled={loading}
|
|
>
|
|
{s === 'all' ? t('Alle') : scopeLabels[s as AutoTemplateScope]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={() => load()}
|
|
disabled={loading}
|
|
>
|
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.tableContainer}>
|
|
<FormGeneratorTable<AutoWorkflowTemplate>
|
|
data={templates}
|
|
columns={columns}
|
|
loading={loading}
|
|
pagination={true}
|
|
pageSize={25}
|
|
searchable={true}
|
|
filterable={true}
|
|
sortable={true}
|
|
selectable={true}
|
|
apiEndpoint={`/api/workflows/${instanceId}/templates`}
|
|
actionButtons={[
|
|
{
|
|
type: 'edit',
|
|
title: t('Im Editor öffnen'),
|
|
onAction: handleEdit,
|
|
},
|
|
{
|
|
type: 'delete',
|
|
title: t('Löschen'),
|
|
},
|
|
]}
|
|
customActions={[
|
|
{
|
|
id: 'rename',
|
|
icon: <FaPen />,
|
|
title: t('Umbenennen'),
|
|
onClick: (row) => handleRename(row),
|
|
visible: (row) => (row.templateScope || 'user') !== 'system',
|
|
},
|
|
{
|
|
id: 'copy',
|
|
icon: <FaCopy />,
|
|
title: t('Als Workflow kopieren'),
|
|
onClick: (row) => handleCopy(row),
|
|
loading: (row) => copyingId === row.id,
|
|
},
|
|
{
|
|
id: 'scope',
|
|
icon: <FaShareAlt />,
|
|
title: t('Bereich ändern'),
|
|
onClick: (row) => setScopeMenuId(scopeMenuId === row.id ? null : row.id),
|
|
loading: (row) => sharingId === row.id,
|
|
visible: (row) => (row.templateScope || 'user') !== 'system',
|
|
},
|
|
]}
|
|
onDelete={(row) => handleDelete(row.id)}
|
|
hookData={{ refetch: load, handleDelete: (id: string) => handleDelete(id), pagination: paginationMeta }}
|
|
emptyMessage={t('Keine Vorlagen gefunden. Erstelle eine.')}
|
|
/>
|
|
</div>
|
|
|
|
{/* Scope change dropdown overlay */}
|
|
{scopeMenuId && (() => {
|
|
const tpl = templates.find((row) => row.id === scopeMenuId);
|
|
if (!tpl) return null;
|
|
const currentScope = (tpl.templateScope || 'user') as AutoTemplateScope;
|
|
const scopes: AutoTemplateScope[] = ['user', 'instance', 'mandate'];
|
|
return (
|
|
<div
|
|
style={{ position: 'fixed', inset: 0, zIndex: 1000 }}
|
|
onClick={() => setScopeMenuId(null)}
|
|
>
|
|
<div
|
|
style={{
|
|
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
|
background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
|
|
borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: 16, minWidth: 220,
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>{t('Bereich ändern')}</h4>
|
|
<p style={{ margin: '0 0 12px', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
|
|
{t('Aktuell:')} <strong>{scopeLabels[currentScope]}</strong>
|
|
</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{scopes.map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => { handleShare(tpl, s); setScopeMenuId(null); }}
|
|
disabled={s === currentScope || sharingId === tpl.id}
|
|
style={{
|
|
padding: '6px 12px', border: '1px solid var(--border-color, #ddd)',
|
|
borderRadius: 4, background: s === currentScope ? 'var(--bg-secondary, #f0f0f0)' : 'transparent',
|
|
cursor: s === currentScope ? 'default' : 'pointer', textAlign: 'left', fontSize: '0.85rem',
|
|
fontWeight: s === currentScope ? 600 : 400,
|
|
}}
|
|
>
|
|
{scopeLabels[s]} {s === currentScope && t('(aktuell)')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => setScopeMenuId(null)}
|
|
style={{ marginTop: 12, padding: '4px 12px', border: '1px solid var(--border-color, #ddd)', borderRadius: 4, background: 'transparent', cursor: 'pointer', fontSize: '0.8rem' }}
|
|
>
|
|
{t('Abbrechen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
<PromptDialog />
|
|
</div>
|
|
);
|
|
};
|