444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
/**
|
|
* Start node (Zeitplan) — Karten-UI mit Konfiguration unter der gewählten Option.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
|
import type { NodeConfigRendererProps } from '../configs/types';
|
|
import {
|
|
type ScheduleSpec,
|
|
type ScheduleMode,
|
|
type IntervalUnit,
|
|
type CalendarPeriod,
|
|
buildCronFromSpec,
|
|
scheduleSpecFromParams,
|
|
WEEKDAYS_MO_SO,
|
|
} from '../runtime/scheduleCron';
|
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
|
|
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
|
|
|
function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
|
|
return [
|
|
{ value: 'daily', title: t('scheduleStartNodeConfig.taeglich'), subtitle: t('scheduleStartNodeConfig.jedenTagZurGleichenZeit') },
|
|
{ value: 'weekdays', title: t('scheduleStartNodeConfig.werktage'), subtitle: t('scheduleStartNodeConfig.montagBisFreitag') },
|
|
{ value: 'weekly', title: t('scheduleStartNodeConfig.bestimmteTage'), subtitle: t('scheduleStartNodeConfig.wochentageAuswaehlen') },
|
|
{ value: 'calendar', title: t('scheduleStartNodeConfig.einAndererZeitraum'), subtitle: t('scheduleStartNodeConfig.monatlichOderJaehrlich') },
|
|
{ value: 'interval', title: t('scheduleStartNodeConfig.intervall'), subtitle: t('scheduleStartNodeConfig.inRegelmaessigenAbstaenden') },
|
|
];
|
|
}
|
|
|
|
const MONTH_NAMES_DE = [
|
|
'Januar',
|
|
'Februar',
|
|
'März',
|
|
'April',
|
|
'Mai',
|
|
'Juni',
|
|
'Juli',
|
|
'August',
|
|
'September',
|
|
'Oktober',
|
|
'November',
|
|
'Dezember',
|
|
];
|
|
|
|
function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] {
|
|
return [
|
|
{ value: 'seconds', label: t('scheduleStartNodeConfig.sek'), title: t('scheduleStartNodeConfig.sekunden') },
|
|
{ value: 'minutes', label: t('scheduleStartNodeConfig.min'), title: t('scheduleStartNodeConfig.minuten') },
|
|
{ value: 'hours', label: t('scheduleStartNodeConfig.h'), title: t('scheduleStartNodeConfig.stunden') },
|
|
{ value: 'days', label: t('scheduleStartNodeConfig.d'), title: t('scheduleStartNodeConfig.tage') },
|
|
{ value: 'years', label: t('scheduleStartNodeConfig.a'), title: t('scheduleStartNodeConfig.jahre') },
|
|
];
|
|
}
|
|
|
|
function timeString(hour: number, minute: number): string {
|
|
return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`;
|
|
}
|
|
|
|
function commitSpec(next: ScheduleSpec, updateParam: (key: string, value: unknown) => void) {
|
|
updateParam('schedule', next);
|
|
updateParam('cron', buildCronFromSpec(next));
|
|
}
|
|
|
|
function clampInterval(value: number, unit: IntervalUnit): number {
|
|
const v = Math.max(1, Math.floor(value) || 1);
|
|
switch (unit) {
|
|
case 'seconds':
|
|
return Math.min(59, v);
|
|
case 'minutes':
|
|
return Math.min(59, v);
|
|
case 'hours':
|
|
return Math.min(23, v);
|
|
case 'days':
|
|
return Math.min(31, v);
|
|
case 'years':
|
|
return Math.min(99, v);
|
|
default:
|
|
return v;
|
|
}
|
|
}
|
|
|
|
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
|
|
|
|
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
|
const { t } = useLanguage();
|
|
const modeOptions = _getModeOptions(t);
|
|
const intervalUnits = _getIntervalUnits(t);
|
|
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
|
|
const prefersReducedMotion = useReducedMotion();
|
|
const specModeRef = useRef(spec.mode);
|
|
specModeRef.current = spec.mode;
|
|
|
|
useEffect(() => {
|
|
const derived = scheduleSpecFromParams(params as Record<string, unknown>);
|
|
console.log('[ScheduleStartNode] useEffect params → setSpec', {
|
|
paramsCron: params.cron,
|
|
paramsSchedule: params.schedule,
|
|
derivedMode: derived.mode,
|
|
previousSpecMode: specModeRef.current,
|
|
});
|
|
setSpec(derived);
|
|
}, [params.cron, params.schedule]);
|
|
|
|
useEffect(() => {
|
|
console.log('[ScheduleStartNode] spec.mode changed (UI sollte passen)', {
|
|
specMode: spec.mode,
|
|
cssBlockBase: styles.scheduleModeBlock,
|
|
cssBlockActive: styles.scheduleModeBlockActive,
|
|
});
|
|
}, [spec.mode]);
|
|
|
|
const push = useCallback(
|
|
(next: ScheduleSpec) => {
|
|
setSpec(next);
|
|
commitSpec(next, updateParam);
|
|
},
|
|
[updateParam]
|
|
);
|
|
|
|
const setMode = (mode: ScheduleMode) => {
|
|
console.log('[ScheduleStartNode] setMode', {
|
|
from: spec.mode,
|
|
to: mode,
|
|
refMode: specModeRef.current,
|
|
});
|
|
const base: ScheduleSpec = { ...spec, mode };
|
|
if (mode === 'weekly' && base.weekdays.length === 0) {
|
|
base.weekdays = [1, 2, 3, 4, 5];
|
|
}
|
|
if (mode === 'calendar') {
|
|
base.calendarPeriod = base.calendarPeriod ?? 'monthly';
|
|
}
|
|
push(base);
|
|
};
|
|
|
|
const onModeCardPointerEvent = (
|
|
phase: 'pointerdown' | 'click',
|
|
e: React.PointerEvent | React.MouseEvent,
|
|
o: { value: ScheduleMode; title: string; subtitle: string }
|
|
) => {
|
|
const el = e.target as HTMLElement;
|
|
const cur = e.currentTarget as HTMLElement;
|
|
const isLast = o.value === 'interval';
|
|
if (!isLast) return;
|
|
const cx = 'clientX' in e ? e.clientX : 0;
|
|
const cy = 'clientY' in e ? e.clientY : 0;
|
|
const hit = typeof document !== 'undefined' ? document.elementFromPoint(cx, cy) : null;
|
|
const hitH = hit as HTMLElement | null;
|
|
console.log(`[ScheduleStartNode] ${phase} — unterstes Element (Intervall)`, {
|
|
intendedMode: o.value,
|
|
title: o.title,
|
|
specModeClosure: spec.mode,
|
|
specModeRef: specModeRef.current,
|
|
eventPhase: e.nativeEvent.eventPhase,
|
|
target: { tag: el?.tagName, className: el?.className, id: el?.id },
|
|
currentTarget: { tag: cur?.tagName, className: cur?.className },
|
|
clientX: cx,
|
|
clientY: cy,
|
|
pointerId: 'pointerId' in e ? (e as React.PointerEvent).pointerId : undefined,
|
|
isTrusted: e.nativeEvent.isTrusted,
|
|
elementFromPoint: hitH
|
|
? {
|
|
tag: hitH.tagName,
|
|
className: hitH.className,
|
|
dataScheduleMode: hitH.closest?.('[data-schedule-mode]')?.getAttribute('data-schedule-mode'),
|
|
textSlice: (hitH.textContent ?? '').slice(0, 60),
|
|
}
|
|
: null,
|
|
});
|
|
};
|
|
|
|
const onTimeChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
const v = ev.target.value;
|
|
if (!v) return;
|
|
const [hs, ms] = v.split(':');
|
|
const hour = Number(hs);
|
|
const minute = Number(ms);
|
|
if (Number.isNaN(hour) || Number.isNaN(minute)) return;
|
|
push({ ...spec, hour, minute });
|
|
};
|
|
|
|
const toggleWeekday = (cronDow: number) => {
|
|
const set = new Set(spec.weekdays);
|
|
if (set.has(cronDow)) set.delete(cronDow);
|
|
else set.add(cronDow);
|
|
let weekdays = [...set];
|
|
if (weekdays.length === 0) weekdays = [cronDow];
|
|
push({ ...spec, weekdays });
|
|
};
|
|
|
|
const setCalendarPeriod = (calendarPeriod: CalendarPeriod) => {
|
|
push({ ...spec, calendarPeriod });
|
|
};
|
|
|
|
const setIntervalUnit = (intervalUnit: IntervalUnit) => {
|
|
const next = { ...spec, intervalUnit };
|
|
next.intervalValue = clampInterval(next.intervalValue, intervalUnit);
|
|
push(next);
|
|
};
|
|
|
|
const panelTransition = prefersReducedMotion
|
|
? { duration: 0 }
|
|
: {
|
|
height: { duration: 0.44, ease: EASE_SMOOTH },
|
|
opacity: { duration: 0.3, ease: 'easeOut' as const },
|
|
};
|
|
|
|
return (
|
|
<div className={styles.schedulePanel}>
|
|
<p className={styles.startNodeDocIntro}>
|
|
Legen Sie fest, <strong>wann</strong> dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird
|
|
unten automatisch erzeugt.
|
|
</p>
|
|
|
|
<LayoutGroup>
|
|
<div className={styles.scheduleModeStack}>
|
|
{modeOptions.map((o) => (
|
|
<motion.div
|
|
key={o.value}
|
|
data-schedule-mode={o.value}
|
|
data-active={spec.mode === o.value ? 'true' : 'false'}
|
|
className={
|
|
spec.mode === o.value
|
|
? `${styles.scheduleModeBlock} ${styles.scheduleModeBlockActive}`
|
|
: styles.scheduleModeBlock
|
|
}
|
|
>
|
|
<motion.button
|
|
type="button"
|
|
className={styles.scheduleModeCard}
|
|
onPointerDown={(e) => onModeCardPointerEvent('pointerdown', e, o)}
|
|
onClick={(e) => {
|
|
onModeCardPointerEvent('click', e, o);
|
|
setMode(o.value);
|
|
}}
|
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.992 }}
|
|
transition={{ type: 'spring', stiffness: 520, damping: 32 }}
|
|
>
|
|
<span className={styles.scheduleModeCardTitle}>{o.title}</span>
|
|
<span className={styles.scheduleModeCardSubtitle}>{o.subtitle}</span>
|
|
</motion.button>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{spec.mode === o.value && (
|
|
<motion.div
|
|
key={`panel-${o.value}`}
|
|
className={styles.scheduleModeConfigShell}
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={panelTransition}
|
|
style={{ overflow: 'hidden' }}
|
|
>
|
|
<div className={styles.scheduleModeConfig}>
|
|
{o.value === 'daily' && (
|
|
<label className={styles.scheduleFieldRow}>
|
|
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
|
<input
|
|
type="time"
|
|
step={60}
|
|
className={styles.scheduleTimeInput}
|
|
value={timeString(spec.hour, spec.minute)}
|
|
onChange={onTimeChange}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{o.value === 'weekdays' && (
|
|
<label className={styles.scheduleFieldRow}>
|
|
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
|
<input
|
|
type="time"
|
|
step={60}
|
|
className={styles.scheduleTimeInput}
|
|
value={timeString(spec.hour, spec.minute)}
|
|
onChange={onTimeChange}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{o.value === 'weekly' && (
|
|
<>
|
|
<div className={styles.scheduleFieldCol}>
|
|
<span className={styles.scheduleFieldLabel}>Wochentage</span>
|
|
<div className={styles.scheduleWeekdayToggles}>
|
|
{WEEKDAYS_MO_SO.map(({ cronDow, label }) => (
|
|
<button
|
|
key={cronDow}
|
|
type="button"
|
|
className={
|
|
spec.weekdays.includes(cronDow) ? styles.scheduleDayOn : styles.scheduleDayOff
|
|
}
|
|
onClick={() => toggleWeekday(cronDow)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<label className={styles.scheduleFieldRow}>
|
|
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
|
<input
|
|
type="time"
|
|
step={60}
|
|
className={styles.scheduleTimeInput}
|
|
value={timeString(spec.hour, spec.minute)}
|
|
onChange={onTimeChange}
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
|
|
{o.value === 'calendar' && (
|
|
<>
|
|
<div className={styles.scheduleSubModes}>
|
|
<button
|
|
type="button"
|
|
className={
|
|
spec.calendarPeriod === 'monthly'
|
|
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
|
|
: styles.scheduleSubModeBtn
|
|
}
|
|
onClick={() => setCalendarPeriod('monthly')}
|
|
>
|
|
Monatlich
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={
|
|
spec.calendarPeriod === 'yearly'
|
|
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
|
|
: styles.scheduleSubModeBtn
|
|
}
|
|
onClick={() => setCalendarPeriod('yearly')}
|
|
>
|
|
Jährlich
|
|
</button>
|
|
</div>
|
|
|
|
{spec.calendarPeriod === 'monthly' && (
|
|
<label className={styles.scheduleFieldRow}>
|
|
<span className={styles.scheduleFieldLabel}>Monatstag</span>
|
|
<select
|
|
className={styles.scheduleSelect}
|
|
value={spec.monthDay}
|
|
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
|
|
>
|
|
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
|
<option key={d} value={d}>
|
|
{d}.
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{spec.calendarPeriod === 'yearly' && (
|
|
<div className={styles.scheduleYearlyRow}>
|
|
<label className={styles.scheduleFieldRowGrow}>
|
|
<span className={styles.scheduleFieldLabel}>Monat</span>
|
|
<select
|
|
className={styles.scheduleSelect}
|
|
value={spec.monthIndex}
|
|
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
|
|
>
|
|
{MONTH_NAMES_DE.map((name, i) => (
|
|
<option key={i + 1} value={i + 1}>
|
|
{name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className={styles.scheduleFieldRowGrow}>
|
|
<span className={styles.scheduleFieldLabel}>Tag</span>
|
|
<select
|
|
className={styles.scheduleSelect}
|
|
value={spec.monthDay}
|
|
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
|
|
>
|
|
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
|
<option key={d} value={d}>
|
|
{d}.
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<label className={styles.scheduleFieldRow}>
|
|
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
|
|
<input
|
|
type="time"
|
|
step={60}
|
|
className={styles.scheduleTimeInput}
|
|
value={timeString(spec.hour, spec.minute)}
|
|
onChange={onTimeChange}
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
|
|
{o.value === 'interval' && (
|
|
<div className={styles.scheduleIntervalRow}>
|
|
<span className={styles.scheduleFieldLabel}>{t('scheduleStartNodeConfig.alle')}</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
className={styles.scheduleNumberInput}
|
|
value={spec.intervalValue}
|
|
onChange={(e) =>
|
|
push({
|
|
...spec,
|
|
intervalValue: clampInterval(Number(e.target.value) || 1, spec.intervalUnit),
|
|
})
|
|
}
|
|
/>
|
|
<select
|
|
className={styles.scheduleUnitSelect}
|
|
value={spec.intervalUnit}
|
|
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
|
|
title={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
|
|
>
|
|
{intervalUnits.map((u) => (
|
|
<option key={u.value} value={u.value} title={u.title}>
|
|
{u.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
|
|
</LayoutGroup>
|
|
</div>
|
|
);
|
|
};
|