/** * @file ReportSettings.tsx * @description Settings → Reports: scheduled report definitions (`/api/reports`) * — create/edit (name, window, daily/weekly + weekday, local hour, HTML/JSON), * enable switch, Run now, a confirmed Delete, and each report's run history * with download links. Every row action is also on a right-click menu. The * browser's timezone offset is saved with the definition, so its hour is the * user's local hour. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, History, Pencil, Play, Plus, Trash2 } from "lucide-react"; import { api } from "../../lib/api"; import { formatDateTime } from "../../lib/format"; import type { ReportDefinition, ReportInput, ReportRun } from "../../lib/types"; import { Checkbox } from "../Checkbox"; import { ConfirmModal } from "../ConfirmModal"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { Select } from "../Select"; import { Switch } from "../Switch"; import { SettingsSection } from "./SettingsRow"; const EMPTY: ReportInput = { name: "", window_days: 7, frequency: "weekly", hour: 8, weekday: 1, tz_offset: 0, formats: ["html"], enabled: true, }; const HOURS = Array.from({ length: 24 }, (_, h) => h); const pad = (n: number) => String(n).padStart(2, "0"); export function ReportSettings() { const { t, i18n } = useTranslation("settings"); const [items, setItems] = useState(null); const [error, setError] = useState(null); /** The form: a new report (id null) or the one being edited. */ const [form, setForm] = useState<{ id: number | null; value: ReportInput } | null>(null); const [deleting, setDeleting] = useState(null); const [busy, setBusy] = useState(null); const [history, setHistory] = useState<{ id: number; runs: ReportRun[] } | null>(null); const ctx = useContextMenu(); const weekday = (d: number) => new Intl.DateTimeFormat(i18n.resolvedLanguage ?? i18n.language, { weekday: "long" }).format( new Date(Date.UTC(2026, 0, 4 + d)) ); const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err)); const load = useCallback(() => { api.reports .list() .then((r) => setItems(r.reports)) .catch(fail); }, []); useEffect(load, [load]); const showHistory = async (id: number) => { try { const r = await api.reports.runs(id); setHistory({ id, runs: r.runs }); } catch (err) { fail(err); } }; const save = async () => { if (!form) return; setBusy("form"); const value = { ...form.value, tz_offset: new Date().getTimezoneOffset() }; try { if (form.id === null) await api.reports.create(value); else await api.reports.update(form.id, value); setForm(null); setError(null); load(); } catch (err) { fail(err); } finally { setBusy(null); } }; const toggle = async (r: ReportDefinition, enabled: boolean) => { try { await api.reports.update(r.id, { enabled }); load(); } catch (err) { fail(err); } }; const runNow = async (r: ReportDefinition) => { setBusy(r.id); try { const { run } = await api.reports.run(r.id); if (run.status === "error") setError(run.error); load(); if (history?.id === r.id) await showHistory(r.id); } catch (err) { fail(err); } finally { setBusy(null); } }; const remove = async () => { if (!deleting) return; try { await api.reports.remove(deleting.id); if (history?.id === deleting.id) setHistory(null); load(); } catch (err) { fail(err); } finally { setDeleting(null); } }; const edit = (r: ReportDefinition) => setForm({ id: r.id, value: { name: r.name, window_days: r.window_days, frequency: r.frequency, hour: r.hour, weekday: r.weekday, tz_offset: r.tz_offset, formats: r.formats, enabled: r.enabled, }, }); const pick = (fn: (r: ReportDefinition) => void) => () => { const r = ctx.menu?.item; ctx.close(); if (r) fn(r); }; const schedule = (r: ReportInput) => [ r.frequency === "daily" ? t("reports.everyDay", { time: `${pad(r.hour)}:00` }) : t("reports.everyWeek", { day: weekday(r.weekday), time: `${pad(r.hour)}:00` }), t("reports.window", { count: r.window_days }), r.formats.map((f) => f.toUpperCase()).join(", "), ].join(" · "); const set = (patch: Partial) => setForm((f) => (f ? { ...f, value: { ...f.value, ...patch } } : f)); const toggleFormat = (f: "html" | "json", on: boolean) => set({ formats: on ? [...(form?.value.formats ?? []), f] : (form?.value.formats ?? []).filter((x) => x !== f), }); return ( {error && (

{error}

)} {form ? (
set({ weekday: Number(v) })} label={t("reports.weekday")} options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekday(d), }))} />
)}