Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 56s
528 lines
17 KiB
TypeScript
528 lines
17 KiB
TypeScript
// Copyright (c) 2026 PowerOn AG
|
|
// All rights reserved.
|
|
/**
|
|
* Accordion schedule planner → updates ScheduleSpec; parent derives cron via buildCronFromSpec.
|
|
*/
|
|
|
|
import React, { useCallback, useMemo, useState } from 'react';
|
|
import {
|
|
MINUTE_SELECT_OPTIONS,
|
|
WEEKDAYS_MO_SO,
|
|
type ScheduleSpec,
|
|
} from '../../utils/scheduleCron';
|
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
|
import { AccordionList } from '../UiComponents';
|
|
import styles from './SchedulePlanner.module.css';
|
|
|
|
export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom';
|
|
|
|
const PLANNER_MODE_IDS: PlannerModeId[] = [
|
|
'minutes',
|
|
'hours',
|
|
'days',
|
|
'weeks',
|
|
'months',
|
|
'custom',
|
|
];
|
|
|
|
function plannerModeFromSpec(spec: ScheduleSpec): PlannerModeId {
|
|
const m = spec.mode;
|
|
if (m === 'interval') {
|
|
if (spec.intervalUnit === 'hours') return 'hours';
|
|
if (spec.intervalUnit === 'days') return 'days';
|
|
if (spec.intervalUnit === 'minutes') return 'minutes';
|
|
return 'custom';
|
|
}
|
|
if (m === 'minutes') return 'minutes';
|
|
if (m === 'hours') return 'hours';
|
|
if (m === 'days' || m === 'daily') return 'days';
|
|
if (m === 'weeks' || m === 'weekly' || m === 'weekdays') return 'weeks';
|
|
if (m === 'months' || (m === 'calendar' && spec.calendarPeriod === 'monthly')) return 'months';
|
|
return 'custom';
|
|
}
|
|
|
|
function withPlannerMode(spec: ScheduleSpec, id: PlannerModeId): ScheduleSpec {
|
|
const base = { ...spec };
|
|
switch (id) {
|
|
case 'minutes':
|
|
return {
|
|
...base,
|
|
mode: 'minutes',
|
|
intervalValue: Math.max(1, base.intervalValue || 15),
|
|
};
|
|
case 'hours':
|
|
return {
|
|
...base,
|
|
mode: 'hours',
|
|
intervalValue: Math.max(1, base.intervalValue || 1),
|
|
};
|
|
case 'days':
|
|
return {
|
|
...base,
|
|
mode: 'days',
|
|
intervalValue: Math.max(1, base.intervalValue || 1),
|
|
};
|
|
case 'weeks':
|
|
return {
|
|
...base,
|
|
mode: 'weeks',
|
|
weeksInterval: Math.max(1, base.weeksInterval || 1),
|
|
weekdays:
|
|
base.weekdays.length > 0 ? [...base.weekdays] : [1, 2, 3, 4, 5],
|
|
};
|
|
case 'months':
|
|
return {
|
|
...base,
|
|
mode: 'months',
|
|
intervalValue: Math.max(1, base.intervalValue || 1),
|
|
monthDay: Math.min(28, Math.max(1, base.monthDay || 1)),
|
|
};
|
|
case 'custom':
|
|
return {
|
|
...base,
|
|
mode: 'custom',
|
|
customCron: (base.customCron || '0 9 * * *').trim(),
|
|
};
|
|
default:
|
|
return base;
|
|
}
|
|
}
|
|
|
|
export interface SchedulePlannerProps {
|
|
value: ScheduleSpec;
|
|
onChange: (next: ScheduleSpec) => void;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export const SchedulePlanner: React.FC<SchedulePlannerProps> = ({
|
|
value,
|
|
onChange,
|
|
className = '',
|
|
disabled = false,
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const activeMode = useMemo(() => plannerModeFromSpec(value), [value]);
|
|
const [openId, setOpenId] = useState<PlannerModeId | null>(null);
|
|
|
|
const modeMeta = useMemo(
|
|
() =>
|
|
({
|
|
minutes: { label: t('Alle X Minuten') },
|
|
hours: { label: t('Alle X Stunden') },
|
|
days: { label: t('Täglich / alle X Tage') },
|
|
weeks: { label: t('Wöchentlich') },
|
|
months: { label: t('Monatlich') },
|
|
custom: { label: t('Custom (Cron)') },
|
|
}) as const,
|
|
[t]
|
|
);
|
|
|
|
const push = useCallback(
|
|
(next: ScheduleSpec) => {
|
|
if (!disabled) onChange(next);
|
|
},
|
|
[disabled, onChange]
|
|
);
|
|
|
|
const handleOpenChange = (next: PlannerModeId | null) => {
|
|
setOpenId(next);
|
|
if (next != null) {
|
|
push(withPlannerMode(value, next));
|
|
}
|
|
};
|
|
|
|
const hourOptions = useMemo(
|
|
() => Array.from({ length: 24 }, (_, i) => i),
|
|
[]
|
|
);
|
|
|
|
const renderBody = (id: PlannerModeId) => {
|
|
const v = plannerModeFromSpec(value) === id ? value : withPlannerMode(value, id);
|
|
switch (id) {
|
|
case 'minutes':
|
|
return (
|
|
<div className={styles.fields}>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={59}
|
|
className={styles.numberInput}
|
|
value={v.intervalValue}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'minutes',
|
|
intervalValue: Math.min(59, Math.max(1, Number(e.target.value) || 1)),
|
|
})
|
|
}
|
|
/>
|
|
<span className={styles.unit}>{t('Minuten')}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'hours':
|
|
return (
|
|
<div className={styles.fields}>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={23}
|
|
className={styles.numberInput}
|
|
value={v.intervalValue}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'hours',
|
|
intervalValue: Math.min(23, Math.max(1, Number(e.target.value) || 1)),
|
|
})
|
|
}
|
|
/>
|
|
<span className={styles.unit}>{t('Stunden')}</span>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Bei Minute')}</span>
|
|
<select
|
|
className={styles.select}
|
|
value={v.minute}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'hours',
|
|
minute: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
|
<option key={m} value={m}>
|
|
{String(m).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'days':
|
|
return (
|
|
<div className={styles.fields}>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={31}
|
|
className={styles.numberInput}
|
|
value={v.intervalValue}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'days',
|
|
intervalValue: Math.min(31, Math.max(1, Number(e.target.value) || 1)),
|
|
})
|
|
}
|
|
/>
|
|
<span className={styles.unit}>{t('Tage')}</span>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
|
|
<div className={styles.timeRow}>
|
|
<select
|
|
className={styles.select}
|
|
value={v.hour}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'days',
|
|
hour: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{hourOptions.map((h) => (
|
|
<option key={h} value={h}>
|
|
{String(h).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className={styles.timeSep}>:</span>
|
|
<select
|
|
className={styles.select}
|
|
value={v.minute}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'days',
|
|
minute: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
|
<option key={m} value={m}>
|
|
{String(m).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<p className={styles.cronHint}>
|
|
{t(
|
|
'Hinweis: „Alle N Tage“ entspricht in Cron einem Schritt im Tag-des-Monats-Feld, nicht zwingend jedem Kalendertag.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
case 'weeks':
|
|
return (
|
|
<div className={styles.fields}>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={52}
|
|
className={styles.numberInput}
|
|
value={v.weeksInterval ?? 1}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'weeks',
|
|
weeksInterval: Math.min(52, Math.max(1, Number(e.target.value) || 1)),
|
|
})
|
|
}
|
|
/>
|
|
<span className={styles.unit}>{t('Wochen')}</span>
|
|
</div>
|
|
{(v.weeksInterval ?? 1) > 1 && (
|
|
<p className={styles.warn}>
|
|
{t(
|
|
'Mehr als jede Woche: der erzeugte Cron entspricht vorläufig wöchentlich (ein Wochen-Intervall > 1 ist im Standard-Cron nicht exakt abbildbar).'
|
|
)}
|
|
</p>
|
|
)}
|
|
<div className={`${styles.field} ${styles.fieldStart}`}>
|
|
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
|
|
{t('Wochentage')}
|
|
</span>
|
|
<div className={styles.daysRow}>
|
|
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
|
|
<button
|
|
key={cronDow}
|
|
type="button"
|
|
data-schedule-day=""
|
|
className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`}
|
|
onClick={() => {
|
|
const next = { ...v, mode: 'weeks' as const };
|
|
const set = new Set(next.weekdays);
|
|
if (set.has(cronDow)) set.delete(cronDow);
|
|
else set.add(cronDow);
|
|
let wd = [...set];
|
|
if (wd.length === 0) wd = [cronDow];
|
|
wd.sort((a, b) => {
|
|
const o = (x: number) => (x === 0 ? 7 : x);
|
|
return o(a) - o(b);
|
|
});
|
|
push({ ...next, weekdays: wd });
|
|
}}
|
|
>
|
|
{cronDow === 1
|
|
? t('Mo')
|
|
: cronDow === 2
|
|
? t('Di')
|
|
: cronDow === 3
|
|
? t('Mi')
|
|
: cronDow === 4
|
|
? t('Do')
|
|
: cronDow === 5
|
|
? t('Fr')
|
|
: cronDow === 6
|
|
? t('Sa')
|
|
: t('So')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
|
|
<div className={styles.timeRow}>
|
|
<select
|
|
className={styles.select}
|
|
value={v.hour}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'weeks',
|
|
hour: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{hourOptions.map((h) => (
|
|
<option key={h} value={h}>
|
|
{String(h).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className={styles.timeSep}>:</span>
|
|
<select
|
|
className={styles.select}
|
|
value={v.minute}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'weeks',
|
|
minute: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
|
<option key={m} value={m}>
|
|
{String(m).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'months':
|
|
return (
|
|
<div className={styles.fields}>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={12}
|
|
className={styles.numberInput}
|
|
value={v.intervalValue}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'months',
|
|
intervalValue: Math.min(12, Math.max(1, Number(e.target.value) || 1)),
|
|
})
|
|
}
|
|
/>
|
|
<span className={styles.unit}>{t('Monate')}</span>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Tag des Monats')}</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={28}
|
|
className={styles.numberInput}
|
|
value={Math.min(28, v.monthDay)}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'months',
|
|
monthDay: Math.min(28, Math.max(1, Number(e.target.value) || 1)),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
|
|
<div className={styles.timeRow}>
|
|
<select
|
|
className={styles.select}
|
|
value={v.hour}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'months',
|
|
hour: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{hourOptions.map((h) => (
|
|
<option key={h} value={h}>
|
|
{String(h).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className={styles.timeSep}>:</span>
|
|
<select
|
|
className={styles.select}
|
|
value={v.minute}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'months',
|
|
minute: Number(e.target.value),
|
|
})
|
|
}
|
|
>
|
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
|
<option key={m} value={m}>
|
|
{String(m).padStart(2, '0')}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'custom':
|
|
return (
|
|
<div className={styles.fields}>
|
|
<div className={`${styles.field} ${styles.fieldStart}`}>
|
|
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
|
|
{t('Ausdruck')}
|
|
</span>
|
|
<div>
|
|
<input
|
|
type="text"
|
|
className={styles.cronInput}
|
|
value={v.customCron}
|
|
onChange={(e) =>
|
|
push({
|
|
...v,
|
|
mode: 'custom',
|
|
customCron: e.target.value,
|
|
})
|
|
}
|
|
spellCheck={false}
|
|
/>
|
|
<div className={styles.cronHint}>
|
|
{t('Cron')}: {t('Minute')} · {t('Stunde')} · {t('Tag')} · {t('Monat')} ·{' '}
|
|
{t('Wochentag')}
|
|
{' · '}[{t('Sekunde')} {t('optional')}]
|
|
<br />
|
|
{t('z.B.')}{' '}
|
|
<code>0 9 * * 3,5</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`${styles.wrap} ${className} ${disabled ? styles.disabled : ''}`}>
|
|
<p className={styles.intro}>
|
|
{t(
|
|
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
|
|
)}
|
|
</p>
|
|
<AccordionList<PlannerModeId>
|
|
items={PLANNER_MODE_IDS.map((mid) => ({
|
|
id: mid,
|
|
title: modeMeta[mid].label,
|
|
children: renderBody(mid),
|
|
}))}
|
|
showSelectionIndicator
|
|
selectedId={activeMode}
|
|
openId={openId}
|
|
onOpenChange={handleOpenChange}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|