3,200+ hardcoded color values replaced with CSS variable-backed Tailwind classes (bg-card, text-foreground, border-border, etc.). Enables light mode via CSS variable swap. Only syntax highlighting colors and intentional one-offs remain hardcoded (~15 values). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
8.6 KiB
TypeScript
242 lines
8.6 KiB
TypeScript
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, onScheduleLoaded])
|
|
|
|
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-hidden 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-hidden 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-hidden 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-hidden 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-hidden 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-primary text-white hover:brightness-110'
|
|
: '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>
|
|
)
|
|
}
|