ui-nyla/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx
2026-04-09 00:11:35 +02:00

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>
);
};