feat: add MaintenanceScheduleSection with schedule builder and summary

Schedule draft state is local UI only (not in store). Hydrates form from
existing schedule on load. Includes getScheduleSummary helper for collapsed
section display. Two-stage save: tree first, schedule second. Schedule
failure shows actionable error without rolling back tree save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-19 03:20:46 -05:00
parent 80e7831e90
commit a60103e337

View File

@@ -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<MaintenanceSchedule | null>(null)
const [targetLists, setTargetLists] = useState<TargetList[]>([])
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<string>('')
// 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 (
<div className="py-3 text-sm text-muted-foreground">Loading schedule...</div>
)
}
return (
<div className="space-y-4">
{/* Frequency */}
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(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"
>
{FREQUENCY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Hour</label>
<input
type="number"
min={0}
max={23}
value={hour}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Minute</label>
<input
type="number"
min={0}
max={59}
value={minute}
onChange={(e) => 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"
/>
</div>
</div>
{/* Timezone */}
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Timezone</label>
<select
value={timezone}
onChange={(e) => setTimezone(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_OPTIONS.map(tz => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
{/* Target List */}
{targetLists.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Target List (optional)</label>
<select
value={selectedTargetListId}
onChange={(e) => setSelectedTargetListId(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"
>
<option value="">None manual targets only</option>
{targetLists.map(tl => (
<option key={tl.id} value={tl.id}>{tl.name} ({tl.targets.length} targets)</option>
))}
</select>
</div>
)}
{/* Save button */}
<button
onClick={handleSaveSchedule}
disabled={isSaving || !treeId}
className={cn(
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
treeId
? 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90'
: 'cursor-not-allowed border border-border bg-card text-muted-foreground opacity-50'
)}
>
<Clock className="h-4 w-4" />
{isSaving ? 'Saving...' : schedule ? 'Update Schedule' : 'Create Schedule'}
</button>
{!treeId && (
<p className="text-xs text-muted-foreground">Save the flow first to configure a schedule.</p>
)}
</div>
)
}
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}`
}