ui-nyla/src/components/SchedulePlanner/SchedulePlanner.tsx
ValueOn AG 7eb305f910
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 56s
cp adapted to 2026 poweron
2026-06-09 09:53:38 +02:00

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