feat(ui): schedules management page

Add /schedules page with list view (name, kind, definition summary, timezone, enabled, next run) and create/edit/delete/enable-disable actions. ScheduleFormDialog supports both Easy (interval + Minutes/Hours/Days) and Cron (six-field expression) kinds with UTC/Local timezone selection, matching the backend ScheduleRequest/Response shape.
This commit is contained in:
GraceSolutions
2026-04-23 14:17:51 -04:00
parent 999415f942
commit b07a46ef4f
2 changed files with 349 additions and 0 deletions
@@ -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<void>;
}
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<number | "">(5);
const [easyIntervalUnit, setEasyIntervalUnit] = useState("Minutes");
const [cronExpression, setCronExpression] = useState("");
const [timezoneMode, setTimezoneMode] = useState("UTC");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<Schedule> = {
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 (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>{schedule ? "Edit Schedule" : "New Schedule"}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{error && <Alert severity="error">{error}</Alert>}
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} required fullWidth />
<TextField label="Description" value={description} onChange={(e) => setDescription(e.target.value)} fullWidth multiline rows={2} />
<FormControlLabel control={<Switch checked={isEnabled} onChange={(_, v) => setIsEnabled(v)} />} label="Enabled" />
<TextField select label="Kind" value={scheduleKind} onChange={(e) => setScheduleKind(e.target.value)} fullWidth>
{KINDS.map((k) => (
<MenuItem key={k.value} value={k.value}>{k.label}</MenuItem>
))}
</TextField>
{scheduleKind === "Easy" ? (
<Stack direction="row" spacing={2}>
<TextField
label="Every"
type="number"
value={easyIntervalValue}
onChange={(e) => setEasyIntervalValue(e.target.value === "" ? "" : Number(e.target.value))}
fullWidth
/>
<TextField select label="Unit" value={easyIntervalUnit} onChange={(e) => setEasyIntervalUnit(e.target.value)} fullWidth>
{UNITS.map((u) => (
<MenuItem key={u} value={u}>{u}</MenuItem>
))}
</TextField>
</Stack>
) : (
<Stack spacing={0.5}>
<TextField
label="Cron Expression"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
fullWidth
placeholder="0 */5 * * * *"
/>
<Typography variant="caption" color="textSecondary">
Six fields: seconds minutes hours day-of-month month day-of-week
</Typography>
</Stack>
)}
<TextField select label="Timezone" value={timezoneMode} onChange={(e) => setTimezoneMode(e.target.value)} fullWidth>
{TIMEZONES.map((t) => (
<MenuItem key={t} value={t}>{t}</MenuItem>
))}
</TextField>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained" disabled={saving}>
{saving ? "Saving..." : schedule ? "Save" : "Create"}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -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<Schedule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<Schedule | null>(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 (
<PageContainer title="Schedules" description="Reusable schedule definitions">
<DashboardCard
title="Schedules"
subtitle="Easy intervals or six-field cron expressions reusable across rules"
action={
<Button
variant="contained"
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
New Schedule
</Button>
}
>
<Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{loading && items.length === 0 ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : items.length === 0 ? (
<Typography variant="body2" color="textSecondary">
No schedules yet. Click &ldquo;New Schedule&rdquo; to add one.
</Typography>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Definition</TableCell>
<TableCell>Timezone</TableCell>
<TableCell>Enabled</TableCell>
<TableCell>Next Run</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((s) => (
<TableRow key={s.id} hover>
<TableCell>
<Stack>
<Typography variant="body2" fontWeight={600}>
{s.name}
</Typography>
{s.description && (
<Typography variant="caption" color="textSecondary">
{s.description}
</Typography>
)}
</Stack>
</TableCell>
<TableCell>
<Chip size="small" label={s.scheduleKind} variant="outlined" />
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontFamily: "monospace" }}>
{summarize(s)}
</Typography>
</TableCell>
<TableCell>{s.timezoneMode}</TableCell>
<TableCell>
<Switch size="small" checked={s.isEnabled} onChange={() => handleToggle(s)} />
</TableCell>
<TableCell>{formatDateTime(s.nextRunUtc)}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" onClick={() => { setEditing(s); setFormOpen(true); }}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(s)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</DashboardCard>
<ScheduleFormDialog
open={formOpen}
schedule={editing}
onClose={() => setFormOpen(false)}
onSaved={async () => { setFormOpen(false); await load(); }}
/>
</PageContainer>
);
}