diff --git a/frontend/src/app/(DashboardLayout)/schedules/ScheduleFormDialog.tsx b/frontend/src/app/(DashboardLayout)/schedules/ScheduleFormDialog.tsx new file mode 100644 index 0000000..a2b30d3 --- /dev/null +++ b/frontend/src/app/(DashboardLayout)/schedules/ScheduleFormDialog.tsx @@ -0,0 +1,163 @@ +"use client"; + +import Alert from "@mui/material/Alert"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useEffect, useState } from "react"; + +import { ApiError } from "@/lib/api/client"; +import { SchedulesApi } from "@/lib/api/resources"; +import type { Schedule } from "@/lib/api/types"; + +interface Props { + open: boolean; + schedule: Schedule | null; + onClose: () => void; + onSaved: () => void | Promise; +} + +const KINDS = [ + { value: "Easy", label: "Easy (fixed interval)" }, + { value: "Cron", label: "Cron expression" }, +]; + +const UNITS = ["Minutes", "Hours", "Days"]; +const TIMEZONES = ["UTC", "Local"]; + +export default function ScheduleFormDialog({ open, schedule, onClose, onSaved }: Props) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isEnabled, setIsEnabled] = useState(true); + const [scheduleKind, setScheduleKind] = useState("Easy"); + const [easyIntervalValue, setEasyIntervalValue] = useState(5); + const [easyIntervalUnit, setEasyIntervalUnit] = useState("Minutes"); + const [cronExpression, setCronExpression] = useState(""); + const [timezoneMode, setTimezoneMode] = useState("UTC"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setError(null); + if (schedule) { + setName(schedule.name); + setDescription(schedule.description ?? ""); + setIsEnabled(schedule.isEnabled); + setScheduleKind(schedule.scheduleKind || "Easy"); + setEasyIntervalValue(schedule.easyIntervalValue ?? 5); + setEasyIntervalUnit(schedule.easyIntervalUnit ?? "Minutes"); + setCronExpression(schedule.cronExpression ?? ""); + setTimezoneMode(schedule.timezoneMode || "UTC"); + } else { + setName(""); + setDescription(""); + setIsEnabled(true); + setScheduleKind("Easy"); + setEasyIntervalValue(5); + setEasyIntervalUnit("Minutes"); + setCronExpression(""); + setTimezoneMode("UTC"); + } + }, [open, schedule]); + + const handleSubmit = async () => { + if (!name.trim()) return setError("Name is required"); + if (scheduleKind === "Easy" && (easyIntervalValue === "" || easyIntervalValue <= 0)) + return setError("Easy interval must be a positive number"); + if (scheduleKind === "Cron" && !cronExpression.trim()) + return setError("Cron expression is required"); + + setSaving(true); + setError(null); + try { + const body: Partial = { + name: name.trim(), + description: description.trim() || undefined, + isEnabled, + scheduleKind, + timezoneMode, + easyIntervalValue: scheduleKind === "Easy" ? Number(easyIntervalValue) : undefined, + easyIntervalUnit: scheduleKind === "Easy" ? easyIntervalUnit : undefined, + cronExpression: scheduleKind === "Cron" ? cronExpression.trim() : undefined, + }; + if (schedule) await SchedulesApi.update(schedule.id, body); + else await SchedulesApi.create(body); + await onSaved(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to save schedule"); + } finally { + setSaving(false); + } + }; + + return ( + + {schedule ? "Edit Schedule" : "New Schedule"} + + + {error && {error}} + setName(e.target.value)} required fullWidth /> + setDescription(e.target.value)} fullWidth multiline rows={2} /> + setIsEnabled(v)} />} label="Enabled" /> + + setScheduleKind(e.target.value)} fullWidth> + {KINDS.map((k) => ( + {k.label} + ))} + + + {scheduleKind === "Easy" ? ( + + setEasyIntervalValue(e.target.value === "" ? "" : Number(e.target.value))} + fullWidth + /> + setEasyIntervalUnit(e.target.value)} fullWidth> + {UNITS.map((u) => ( + {u} + ))} + + + ) : ( + + setCronExpression(e.target.value)} + fullWidth + placeholder="0 */5 * * * *" + /> + + Six fields: seconds minutes hours day-of-month month day-of-week + + + )} + + setTimezoneMode(e.target.value)} fullWidth> + {TIMEZONES.map((t) => ( + {t} + ))} + + + + + + + + + ); +} diff --git a/frontend/src/app/(DashboardLayout)/schedules/page.tsx b/frontend/src/app/(DashboardLayout)/schedules/page.tsx new file mode 100644 index 0000000..576d21e --- /dev/null +++ b/frontend/src/app/(DashboardLayout)/schedules/page.tsx @@ -0,0 +1,186 @@ +"use client"; + +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { useCallback, useEffect, useState } from "react"; + +import PageContainer from "@/app/components/container/PageContainer"; +import DashboardCard from "@/app/components/shared/DashboardCard"; +import { ApiError } from "@/lib/api/client"; +import { SchedulesApi } from "@/lib/api/resources"; +import type { Schedule } from "@/lib/api/types"; +import { formatDateTime } from "@/lib/format"; + +import ScheduleFormDialog from "./ScheduleFormDialog"; + +export default function SchedulesPage() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await SchedulesApi.list({ pageSize: 100 }); + setItems(res.items); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to load schedules"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleToggle = async (s: Schedule) => { + try { + if (s.isEnabled) await SchedulesApi.disable(s.id); + else await SchedulesApi.enable(s.id); + await load(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to toggle schedule"); + } + }; + + const handleDelete = async (s: Schedule) => { + if (!window.confirm(`Delete schedule "${s.name}"?`)) return; + try { + await SchedulesApi.remove(s.id); + await load(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to delete schedule"); + } + }; + + const summarize = (s: Schedule) => { + if (s.scheduleKind === "Cron") return s.cronExpression || "-"; + if (s.scheduleKind === "Easy" && s.easyIntervalValue && s.easyIntervalUnit) + return `Every ${s.easyIntervalValue} ${s.easyIntervalUnit.toLowerCase()}`; + return "-"; + }; + + return ( + + { + setEditing(null); + setFormOpen(true); + }} + > + New Schedule + + } + > + + {error && ( + setError(null)}> + {error} + + )} + + {loading && items.length === 0 ? ( + + + + ) : items.length === 0 ? ( + + No schedules yet. Click “New Schedule” to add one. + + ) : ( + + + + + Name + Kind + Definition + Timezone + Enabled + Next Run + Actions + + + + {items.map((s) => ( + + + + + {s.name} + + {s.description && ( + + {s.description} + + )} + + + + + + + + {summarize(s)} + + + {s.timezoneMode} + + handleToggle(s)} /> + + {formatDateTime(s.nextRunUtc)} + + + { setEditing(s); setFormOpen(true); }}> + + + + + handleDelete(s)}> + + + + + + ))} + +
+
+ )} +
+
+ + setFormOpen(false)} + onSaved={async () => { setFormOpen(false); await load(); }} + /> +
+ ); +}