diff --git a/frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx b/frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx new file mode 100644 index 00000000..d41fdbcb --- /dev/null +++ b/frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx @@ -0,0 +1,264 @@ +import { useState, useEffect } from 'react' +import { Clock } from 'lucide-react' +import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules' +import { targetListsApi } from '@/api/targetLists' +import type { MaintenanceSchedule, TargetList } from '@/types' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +interface MaintenanceScheduleSectionProps { + treeId: string | null + onScheduleLoaded?: (schedule: MaintenanceSchedule | null, targetList: TargetList | null) => void +} + +const FREQUENCY_OPTIONS = [ + { value: 'daily', label: 'Daily', cron: (hour: number, min: number) => `${min} ${hour} * * *` }, + { value: 'weekly-mon', label: 'Weekly (Monday)', cron: (hour: number, min: number) => `${min} ${hour} * * 1` }, + { value: 'weekly-fri', label: 'Weekly (Friday)', cron: (hour: number, min: number) => `${min} ${hour} * * 5` }, + { value: 'monthly', label: 'Monthly (1st)', cron: (hour: number, min: number) => `${min} ${hour} 1 * *` }, +] as const + +const TIMEZONE_OPTIONS = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Berlin', + 'Asia/Tokyo', + 'Australia/Sydney', +] + +export function MaintenanceScheduleSection({ treeId, onScheduleLoaded }: MaintenanceScheduleSectionProps) { + const [schedule, setSchedule] = useState(null) + const [targetLists, setTargetLists] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + // Draft form state (local UI only — not in store) + const [frequency, setFrequency] = useState('weekly-mon') + const [hour, setHour] = useState(9) + const [minute, setMinute] = useState(0) + const [timezone, setTimezone] = useState('UTC') + const [selectedTargetListId, setSelectedTargetListId] = useState('') + + // Load existing schedule and target lists + useEffect(() => { + const load = async () => { + setIsLoading(true) + try { + const lists = await targetListsApi.list() + setTargetLists(lists) + + if (treeId) { + try { + const existing = await maintenanceSchedulesApi.getForTree(treeId) + setSchedule(existing) + // Hydrate form from existing schedule + hydrateFromSchedule(existing) + if (existing.target_list_id) { + setSelectedTargetListId(existing.target_list_id) + const tl = lists.find(l => l.id === existing.target_list_id) || null + onScheduleLoaded?.(existing, tl) + } else { + onScheduleLoaded?.(existing, null) + } + } catch { + // 404 = no schedule yet (valid state) + onScheduleLoaded?.(null, null) + } + } + } catch { + // Target lists may not load — non-critical + } finally { + setIsLoading(false) + } + } + load() + }, [treeId]) + + const hydrateFromSchedule = (s: MaintenanceSchedule) => { + const parts = s.cron_expression.split(' ') + const min = parseInt(parts[0] || '0', 10) + const hr = parseInt(parts[1] || '9', 10) + setMinute(min) + setHour(hr) + setTimezone(s.timezone) + + // Detect frequency from cron + const dayOfMonth = parts[2] + const dayOfWeek = parts[4] + if (dayOfMonth === '1') { + setFrequency('monthly') + } else if (dayOfWeek === '*') { + setFrequency('daily') + } else if (dayOfWeek === '5') { + setFrequency('weekly-fri') + } else { + setFrequency('weekly-mon') + } + } + + const handleSaveSchedule = async () => { + if (!treeId) { + toast.error('Save the flow first before configuring a schedule') + return + } + + setIsSaving(true) + try { + const freqOption = FREQUENCY_OPTIONS.find(f => f.value === frequency) + const cronExpression = freqOption?.cron(hour, minute) ?? `${minute} ${hour} * * 1` + + if (schedule) { + const updated = await maintenanceSchedulesApi.update(schedule.id, { + cron_expression: cronExpression, + timezone, + target_list_id: selectedTargetListId || undefined, + }) + setSchedule(updated) + const tl = targetLists.find(l => l.id === selectedTargetListId) || null + onScheduleLoaded?.(updated, tl) + toast.success('Schedule updated') + } else { + const created = await maintenanceSchedulesApi.create({ + tree_id: treeId, + cron_expression: cronExpression, + timezone, + target_list_id: selectedTargetListId || undefined, + }) + setSchedule(created) + const tl = targetLists.find(l => l.id === selectedTargetListId) || null + onScheduleLoaded?.(created, tl) + toast.success('Schedule created') + } + } catch { + toast.error('Failed to save schedule. The flow was saved successfully — you can retry the schedule.') + } finally { + setIsSaving(false) + } + } + + if (isLoading) { + return ( +
Loading schedule...
+ ) + } + + return ( +
+ {/* Frequency */} +
+ + +
+ + {/* Time */} +
+
+ + setHour(Number(e.target.value))} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+ + setMinute(Number(e.target.value))} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+ + {/* Timezone */} +
+ + +
+ + {/* Target List */} + {targetLists.length > 0 && ( +
+ + +
+ )} + + {/* Save button */} + + {!treeId && ( +

Save the flow first to configure a schedule.

+ )} +
+ ) +} + +export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string { + if (!schedule) return 'No schedule configured' + + const parts = schedule.cron_expression.split(' ') + const min = parts[0] ?? '0' + const hour = parts[1] ?? '0' + const timeStr = `${hour.padStart(2, '0')}:${min.padStart(2, '0')}` + + const dayOfWeek = parts[4] + const dayOfMonth = parts[2] + let freqStr = 'Custom' + if (dayOfMonth === '1') { + freqStr = 'Monthly (1st)' + } else if (dayOfMonth === '*' && parts[3] === '*') { + if (dayOfWeek === '*') freqStr = 'Daily' + else if (dayOfWeek === '1') freqStr = 'Every Monday' + else if (dayOfWeek === '5') freqStr = 'Every Friday' + } + + const targetStr = targetList ? ` \u00b7 ${targetList.targets.length} target${targetList.targets.length !== 1 ? 's' : ''}` : '' + return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}` +}