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:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
609
flight-comparator/frontend/src/pages/Schedules.tsx
Normal file
609
flight-comparator/frontend/src/pages/Schedules.tsx
Normal 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 = '0–23';
|
||||
if (form.minute < 0 || form.minute > 59)
|
||||
next.minute = '0–59';
|
||||
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">(1–28)</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: 0–23, Minute: 0–59</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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user