feat: add scheduled scans (cron-like recurring scans)

- New `scheduled_scans` table with daily/weekly/monthly frequencies
- asyncio background scheduler loop checks for due schedules every 60s
- 6 REST endpoints: CRUD + toggle enabled + run-now
- `scheduled_scan_id` FK added to scans table; migrated automatically
- Frontend: Schedules page (list + create form), Schedules nav link,
  "Scheduled" badge on ScanDetails when scan was triggered by a schedule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 10:48:43 +01:00
parent ef5a27097d
commit 836c8474eb
9 changed files with 1666 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Scans from './pages/Scans';
import ScanDetails from './pages/ScanDetails';
import Schedules from './pages/Schedules';
import Airports from './pages/Airports';
import Logs from './pages/Logs';
import ErrorBoundary from './components/ErrorBoundary';
@@ -16,6 +17,7 @@ function App() {
<Route index element={<Dashboard />} />
<Route path="scans" element={<Scans />} />
<Route path="scans/:id" element={<ScanDetails />} />
<Route path="schedules" element={<Schedules />} />
<Route path="airports" element={<Airports />} />
<Route path="logs" element={<Logs />} />
</Route>

View File

@@ -23,6 +23,41 @@ export interface Scan {
error_message?: string;
seat_class: string;
adults: number;
scheduled_scan_id?: number;
}
export interface Schedule {
id: number;
origin: string;
country: string;
window_months: number;
seat_class: string;
adults: number;
label?: string;
frequency: 'daily' | 'weekly' | 'monthly';
hour: number;
minute: number;
day_of_week?: number;
day_of_month?: number;
enabled: boolean;
last_run_at?: string;
next_run_at: string;
created_at: string;
recent_scan_ids: number[];
}
export interface CreateScheduleRequest {
origin: string;
country: string;
window_months?: number;
seat_class?: string;
adults?: number;
label?: string;
frequency: 'daily' | 'weekly' | 'monthly';
hour?: number;
minute?: number;
day_of_week?: number;
day_of_month?: number;
}
export interface Route {
@@ -135,6 +170,26 @@ export const airportApi = {
},
};
export const scheduleApi = {
list: (page = 1, limit = 20) =>
api.get<PaginatedResponse<Schedule>>('/schedules', { params: { page, limit } }),
get: (id: number) =>
api.get<Schedule>(`/schedules/${id}`),
create: (data: CreateScheduleRequest) =>
api.post<Schedule>('/schedules', data),
update: (id: number, data: Partial<CreateScheduleRequest> & { enabled?: boolean }) =>
api.patch<Schedule>(`/schedules/${id}`, data),
delete: (id: number) =>
api.delete(`/schedules/${id}`),
runNow: (id: number) =>
api.post<{ scan_id: number }>(`/schedules/${id}/run-now`),
};
export const logApi = {
list: (page = 1, limit = 50, level?: string, search?: string) => {
const params: any = { page, limit };

View File

@@ -7,6 +7,7 @@ import {
ScrollText,
PlaneTakeoff,
Plus,
CalendarClock,
} from 'lucide-react';
import { cn } from '../lib/utils';
@@ -18,8 +19,9 @@ type NavItem = {
const PRIMARY_NAV: NavItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/' },
{ icon: ScanSearch, label: 'Scans', path: '/scans' },
{ icon: MapPin, label: 'Airports', path: '/airports' },
{ icon: ScanSearch, label: 'Scans', path: '/scans' },
{ icon: CalendarClock, label: 'Schedules', path: '/schedules' },
{ icon: MapPin, label: 'Airports', path: '/airports' },
];
const SECONDARY_NAV: NavItem[] = [
@@ -32,6 +34,7 @@ function getPageTitle(pathname: string): string {
if (pathname === '/') return 'Dashboard';
if (pathname.startsWith('/scans/')) return 'Scan Details';
if (pathname === '/scans') return 'New Scan';
if (pathname === '/schedules') return 'Schedules';
if (pathname === '/airports') return 'Airports';
if (pathname === '/logs') return 'Logs';
return 'Flight Radar';

View File

@@ -1,9 +1,10 @@
import { Fragment, useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
PlaneTakeoff,
Calendar,
CalendarClock,
Users,
Armchair,
Clock,
@@ -221,6 +222,16 @@ export default function ScanDetails() {
<h1 className="text-xl font-semibold text-on-surface">
{scan.origin} {scan.country}
</h1>
{scan.scheduled_scan_id != null && (
<Link
to={`/schedules`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-primary-container text-on-primary-container hover:opacity-80 transition-opacity"
title={`Scheduled scan #${scan.scheduled_scan_id}`}
>
<CalendarClock size={11} aria-hidden="true" />
Scheduled
</Link>
)}
</div>
<StatusChip status={scan.status as ScanStatus} />
</div>

View File

@@ -0,0 +1,609 @@
import { useEffect, useState } from 'react';
import {
Globe,
PlaneTakeoff,
Minus,
Plus,
Play,
Trash2,
CalendarClock,
} from 'lucide-react';
import { scheduleApi } from '../api';
import type { Schedule, CreateScheduleRequest } from '../api';
import AirportSearch from '../components/AirportSearch';
import SegmentedButton from '../components/SegmentedButton';
import AirportChip from '../components/AirportChip';
import Button from '../components/Button';
import Toast from '../components/Toast';
import EmptyState from '../components/EmptyState';
import { cn } from '../lib/utils';
// ── Helpers ──────────────────────────────────────────────────────────────────
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
function formatNextRun(utcStr: string): string {
// utcStr is like "2026-03-01 06:00:00" (no Z suffix from SQLite)
const d = new Date(utcStr.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return utcStr;
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function formatLastRun(utcStr?: string): string {
if (!utcStr) return '—';
const d = new Date(utcStr.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return utcStr;
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function describeSchedule(s: Schedule): string {
const pad = (n: number) => String(n).padStart(2, '0');
const time = `${pad(s.hour)}:${pad(s.minute)} UTC`;
if (s.frequency === 'daily') return `Every day at ${time}`;
if (s.frequency === 'weekly') return `Every ${DAYS[s.day_of_week ?? 0]} at ${time}`;
if (s.frequency === 'monthly') return `${s.day_of_month}th of month at ${time}`;
return s.frequency;
}
// ── Form state ────────────────────────────────────────────────────────────────
interface FormState {
origin: string;
country: string;
window_months: number;
seat_class: string;
adults: number;
label: string;
frequency: 'daily' | 'weekly' | 'monthly';
hour: number;
minute: number;
day_of_week: number;
day_of_month: number;
}
const defaultForm = (): FormState => ({
origin: '',
country: '',
window_months: 1,
seat_class: 'economy',
adults: 1,
label: '',
frequency: 'weekly',
hour: 6,
minute: 0,
day_of_week: 0,
day_of_month: 1,
});
interface FormErrors {
origin?: string;
country?: string;
airports?: string;
hour?: string;
minute?: string;
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function Schedules() {
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [destinationMode, setDestinationMode] = useState<'country' | 'airports'>('country');
const [selectedAirports, setSelectedAirports] = useState<string[]>([]);
const [form, setForm] = useState<FormState>(defaultForm);
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [runningId, setRunningId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
useEffect(() => { loadSchedules(); }, []);
const loadSchedules = async () => {
try {
setLoading(true);
const res = await scheduleApi.list(1, 100);
setSchedules(res.data.data);
} catch {
setToast({ message: 'Failed to load schedules', type: 'error' });
} finally {
setLoading(false);
}
};
const validate = (): boolean => {
const next: FormErrors = {};
if (!form.origin || form.origin.length !== 3)
next.origin = 'Enter a valid 3-letter IATA code';
if (destinationMode === 'country' && (!form.country || form.country.length < 2))
next.country = 'Enter a valid 2-letter country code';
if (destinationMode === 'airports' && selectedAirports.length === 0)
next.airports = 'Add at least one destination airport';
if (form.hour < 0 || form.hour > 23)
next.hour = '023';
if (form.minute < 0 || form.minute > 59)
next.minute = '059';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setSaving(true);
try {
const req: CreateScheduleRequest = {
origin: form.origin,
country: destinationMode === 'country'
? form.country
: selectedAirports.join(','),
window_months: form.window_months,
seat_class: form.seat_class,
adults: form.adults,
label: form.label || undefined,
frequency: form.frequency,
hour: form.hour,
minute: form.minute,
...(form.frequency === 'weekly' ? { day_of_week: form.day_of_week } : {}),
...(form.frequency === 'monthly' ? { day_of_month: form.day_of_month } : {}),
};
await scheduleApi.create(req);
setToast({ message: 'Schedule created', type: 'success' });
setShowForm(false);
setForm(defaultForm());
setSelectedAirports([]);
loadSchedules();
} catch (err: any) {
const msg = err.response?.data?.detail || 'Failed to create schedule';
setToast({ message: typeof msg === 'string' ? msg : JSON.stringify(msg), type: 'error' });
} finally {
setSaving(false);
}
};
const toggleEnabled = async (s: Schedule) => {
try {
const updated = await scheduleApi.update(s.id, { enabled: !s.enabled });
setSchedules(prev => prev.map(x => x.id === s.id ? updated.data : x));
} catch {
setToast({ message: 'Failed to update schedule', type: 'error' });
}
};
const handleRunNow = async (s: Schedule) => {
setRunningId(s.id);
try {
const res = await scheduleApi.runNow(s.id);
setToast({ message: `Scan #${res.data.scan_id} started`, type: 'success' });
loadSchedules();
} catch {
setToast({ message: 'Failed to trigger scan', type: 'error' });
} finally {
setRunningId(null);
}
};
const handleDelete = async (s: Schedule) => {
if (!confirm(`Delete schedule "${s.label || `${s.origin}${s.country}`}"?`)) return;
setDeletingId(s.id);
try {
await scheduleApi.delete(s.id);
setSchedules(prev => prev.filter(x => x.id !== s.id));
setToast({ message: 'Schedule deleted', type: 'success' });
} catch {
setToast({ message: 'Failed to delete schedule', type: 'error' });
} finally {
setDeletingId(null);
}
};
const adjustNumber = (field: 'window_months' | 'adults', delta: number) => {
const limits: Record<string, [number, number]> = { window_months: [1, 12], adults: [1, 9] };
const [min, max] = limits[field];
setForm(prev => ({ ...prev, [field]: Math.min(max, Math.max(min, prev[field] + delta)) }));
};
const inputCls = (hasError?: boolean) =>
`w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ` +
(hasError
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/20');
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
<div className="space-y-4 max-w-4xl">
{/* Header actions */}
<div className="flex items-center justify-between">
<p className="text-sm text-on-surface-variant">
{loading ? 'Loading…' : `${schedules.length} schedule${schedules.length !== 1 ? 's' : ''}`}
</p>
{!showForm && (
<Button variant="filled" onClick={() => setShowForm(true)}>
New Schedule
</Button>
)}
</div>
{/* ── Create Form ─────────────────────────────────────────────── */}
{showForm && (
<form onSubmit={handleCreate} className="space-y-4">
{/* Origin */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">Origin</p>
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Origin Airport
</label>
<AirportSearch
value={form.origin}
onChange={(v) => {
setForm(prev => ({ ...prev, origin: v }));
if (errors.origin) setErrors(prev => ({ ...prev, origin: undefined }));
}}
placeholder="e.g. BDS, MUC, FRA"
hasError={!!errors.origin}
/>
{errors.origin
? <p className="mt-1 text-xs text-error">{errors.origin}</p>
: <p className="mt-1 text-xs text-on-surface-variant">3-letter IATA code</p>}
</div>
</div>
{/* Destination */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">Destination</p>
<SegmentedButton
options={[
{ value: 'country', label: 'By Country', icon: Globe },
{ value: 'airports', label: 'By Airports', icon: PlaneTakeoff },
]}
value={destinationMode}
onChange={(v) => {
setDestinationMode(v as 'country' | 'airports');
setErrors(prev => ({ ...prev, country: undefined, airports: undefined }));
}}
className="mb-4"
/>
{destinationMode === 'country' ? (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Country
</label>
<input
type="text"
value={form.country}
onChange={(e) => {
setForm(prev => ({ ...prev, country: e.target.value.toUpperCase() }));
if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
}}
maxLength={2}
placeholder="e.g. DE, IT, ES"
className={inputCls(!!errors.country)}
/>
{errors.country
? <p className="mt-1 text-xs text-error">{errors.country}</p>
: <p className="mt-1 text-xs text-on-surface-variant">ISO 2-letter country code</p>}
</div>
) : (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Airports
</label>
<AirportSearch
value=""
onChange={(code) => {
if (code && code.length === 3 && !selectedAirports.includes(code)) {
setSelectedAirports(prev => [...prev, code]);
if (errors.airports) setErrors(prev => ({ ...prev, airports: undefined }));
}
}}
clearAfterSelect
placeholder="Search and add airports…"
hasError={!!errors.airports}
/>
{selectedAirports.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedAirports.map(code => (
<AirportChip
key={code}
code={code}
onRemove={() => setSelectedAirports(prev => prev.filter(c => c !== code))}
/>
))}
</div>
)}
{errors.airports
? <p className="mt-1 text-xs text-error">{errors.airports}</p>
: <p className="mt-1 text-xs text-on-surface-variant">
{selectedAirports.length === 0
? 'Search and add destination airports'
: `${selectedAirports.length} airport${selectedAirports.length !== 1 ? 's' : ''} selected`}
</p>}
</div>
)}
</div>
{/* Parameters */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">Parameters</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">Search Window</label>
<div className="flex items-center gap-2">
<button type="button" onClick={() => adjustNumber('window_months', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors">
<Minus size={14} />
</button>
<div className="flex-1 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{form.window_months} {form.window_months === 1 ? 'month' : 'months'}
</div>
<button type="button" onClick={() => adjustNumber('window_months', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors">
<Plus size={14} />
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">Seat Class</label>
<select value={form.seat_class}
onChange={(e) => setForm(prev => ({ ...prev, seat_class: e.target.value }))}
className={inputCls()}>
<option value="economy">Economy</option>
<option value="premium">Premium Economy</option>
<option value="business">Business</option>
<option value="first">First Class</option>
</select>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">Passengers</label>
<div className="flex items-center gap-2">
<button type="button" onClick={() => adjustNumber('adults', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors">
<Minus size={14} />
</button>
<div className="w-32 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{form.adults} {form.adults === 1 ? 'adult' : 'adults'}
</div>
<button type="button" onClick={() => adjustNumber('adults', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors">
<Plus size={14} />
</button>
</div>
</div>
</div>
{/* Schedule */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">Schedule</p>
{/* Optional label */}
<div className="mb-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Label <span className="font-normal opacity-60">(optional)</span>
</label>
<input
type="text"
value={form.label}
onChange={(e) => setForm(prev => ({ ...prev, label: e.target.value }))}
placeholder="e.g. Weekly BDS → Germany"
className={inputCls()}
maxLength={100}
/>
</div>
{/* Frequency */}
<div className="mb-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">Frequency</label>
<div className="flex gap-2">
{(['daily', 'weekly', 'monthly'] as const).map(f => (
<button
key={f}
type="button"
onClick={() => setForm(prev => ({ ...prev, frequency: f }))}
className={cn(
'flex-1 h-10 rounded-xs border text-sm font-medium transition-colors',
form.frequency === f
? 'bg-primary text-on-primary border-primary'
: 'border-outline text-on-surface hover:bg-surface-2',
)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
</div>
{/* Day of week (weekly only) */}
{form.frequency === 'weekly' && (
<div className="mb-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">Day of week</label>
<div className="flex gap-1.5 flex-wrap">
{DAYS.map((d, i) => (
<button
key={d}
type="button"
onClick={() => setForm(prev => ({ ...prev, day_of_week: i }))}
className={cn(
'w-12 h-10 rounded-xs border text-sm font-medium transition-colors',
form.day_of_week === i
? 'bg-primary text-on-primary border-primary'
: 'border-outline text-on-surface hover:bg-surface-2',
)}
>
{d}
</button>
))}
</div>
</div>
)}
{/* Day of month (monthly only) */}
{form.frequency === 'monthly' && (
<div className="mb-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Day of month <span className="font-normal opacity-60">(128)</span>
</label>
<input
type="number"
value={form.day_of_month}
onChange={(e) => setForm(prev => ({ ...prev, day_of_month: Number(e.target.value) }))}
min={1} max={28}
className={cn(inputCls(), 'w-28')}
/>
</div>
)}
{/* Time */}
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Time <span className="font-normal opacity-60">(UTC)</span>
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={form.hour}
onChange={(e) => setForm(prev => ({ ...prev, hour: Number(e.target.value) }))}
min={0} max={23}
className={cn(inputCls(!!errors.hour), 'w-20 text-center')}
placeholder="HH"
/>
<span className="text-on-surface-variant font-bold">:</span>
<input
type="number"
value={form.minute}
onChange={(e) => setForm(prev => ({ ...prev, minute: Number(e.target.value) }))}
min={0} max={59}
className={cn(inputCls(!!errors.minute), 'w-20 text-center')}
placeholder="MM"
/>
</div>
{(errors.hour || errors.minute) && (
<p className="mt-1 text-xs text-error">Hour: 023, Minute: 059</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pb-4">
<Button variant="outlined" type="button"
onClick={() => { setShowForm(false); setForm(defaultForm()); setErrors({}); }}>
Cancel
</Button>
<Button variant="filled" type="submit" loading={saving}>
Create Schedule
</Button>
</div>
</form>
)}
{/* ── Schedules list ───────────────────────────────────────────── */}
{!loading && schedules.length === 0 && !showForm && (
<EmptyState
icon={CalendarClock}
title="No schedules yet"
description="Create a schedule to automatically run scans at a regular interval."
action={{ label: 'New Schedule', onClick: () => setShowForm(true) }}
/>
)}
{schedules.length > 0 && (
<div className="bg-surface rounded-lg shadow-level-1 overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-outline">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Route</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Cadence</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant hidden md:table-cell">Next Run</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant hidden lg:table-cell">Last Run</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Active</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-outline">
{schedules.map(s => (
<tr key={s.id} className="hover:bg-surface-2 transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-on-surface">
{s.label || `${s.origin}${s.country}`}
</div>
{s.label && (
<div className="text-xs text-on-surface-variant mt-0.5">
{s.origin} {s.country}
</div>
)}
</td>
<td className="px-4 py-3 text-on-surface-variant">
{describeSchedule(s)}
</td>
<td className="px-4 py-3 text-on-surface-variant hidden md:table-cell">
{formatNextRun(s.next_run_at)}
</td>
<td className="px-4 py-3 text-on-surface-variant hidden lg:table-cell">
{formatLastRun(s.last_run_at)}
</td>
<td className="px-4 py-3 text-center">
{/* Toggle switch */}
<button
type="button"
onClick={() => toggleEnabled(s)}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none',
s.enabled ? 'bg-primary' : 'bg-outline',
)}
aria-label={s.enabled ? 'Disable schedule' : 'Enable schedule'}
>
<span className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
s.enabled ? 'translate-x-6' : 'translate-x-1',
)} />
</button>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => handleRunNow(s)}
disabled={runningId === s.id}
className="p-2 rounded-xs text-on-surface-variant hover:bg-surface-2 hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Run now"
>
<Play size={15} />
</button>
<button
type="button"
onClick={() => handleDelete(s)}
disabled={deletingId === s.id}
className="p-2 rounded-xs text-on-surface-variant hover:bg-surface-2 hover:text-error disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
title="Delete schedule"
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{toast && (
<Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />
)}
</>
);
}