From f844751142c1b29182ab86c441c91d1f55b37b52 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Mar 2026 17:52:56 +0100 Subject: [PATCH] feat(settings): add report/alert create & edit modals to unified tab --- .../settings/unified/tabs/SiteReportsTab.tsx | 602 +++++++++++++++++- 1 file changed, 571 insertions(+), 31 deletions(-) diff --git a/components/settings/unified/tabs/SiteReportsTab.tsx b/components/settings/unified/tabs/SiteReportsTab.tsx index d8852eb..ad567ab 100644 --- a/components/settings/unified/tabs/SiteReportsTab.tsx +++ b/components/settings/unified/tabs/SiteReportsTab.tsx @@ -1,21 +1,84 @@ 'use client' import { useState } from 'react' -import { Button, toast, Spinner } from '@ciphera-net/ui' +import { Button, toast, Spinner, Modal, Select } from '@ciphera-net/ui' import { Plus, Pencil, Trash, EnvelopeSimple, WebhooksLogo, PaperPlaneTilt } from '@phosphor-icons/react' +import { SiDiscord } from '@icons-pack/react-simple-icons' import { useReportSchedules, useAlertSchedules } from '@/lib/swr/dashboard' -import { deleteReportSchedule, testReportSchedule, updateReportSchedule, type ReportSchedule } from '@/lib/api/report-schedules' +import { useSite } from '@/lib/swr/dashboard' +import { + createReportSchedule, + updateReportSchedule, + deleteReportSchedule, + testReportSchedule, + type ReportSchedule, + type CreateReportScheduleRequest, + type EmailConfig, + type WebhookConfig, +} from '@/lib/api/report-schedules' import { getAuthErrorMessage } from '@ciphera-net/ui' +import { formatDateTime } from '@/lib/utils/formatDate' -function ChannelIcon({ channel }: { channel: string }) { - switch (channel) { - case 'email': return - case 'webhook': return - default: return - } +// ── Helpers ────────────────────────────────────────────────────────────────── + +const TIMEZONES = [ + 'UTC', 'America/New_York', 'America/Los_Angeles', 'America/Chicago', + 'America/Toronto', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', + 'Europe/Amsterdam', 'Asia/Tokyo', 'Asia/Singapore', 'Asia/Dubai', + 'Australia/Sydney', 'Pacific/Auckland', +] + +const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + +const formatHour = (hour: number) => { + if (hour === 0) return '12:00 AM' + if (hour === 12) return '12:00 PM' + return hour < 12 ? `${hour}:00 AM` : `${hour - 12}:00 PM` } -function ScheduleRow({ schedule, siteId, onMutate }: { schedule: ReportSchedule; siteId: string; onMutate: () => void }) { +const ordinalSuffix = (n: number) => { + const s = ['th', 'st', 'nd', 'rd'] + const v = n % 100 + return n + (s[(v - 20) % 10] || s[v] || s[0]) +} + +// ── Icons ──────────────────────────────────────────────────────────────────── + +function SlackIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + + ) +} + +const CHANNEL_ICONS: Record = { + email: , + slack: , + discord: , + webhook: , +} + +function ChannelIcon({ channel }: { channel: string }) { + return <>{CHANNEL_ICONS[channel] ?? } +} + +// ── Schedule Row ───────────────────────────────────────────────────────────── + +function ScheduleRow({ + schedule, + siteId, + onMutate, + onEdit, +}: { + schedule: ReportSchedule + siteId: string + onMutate: () => void + onEdit: (schedule: ReportSchedule) => void +}) { const [testing, setTesting] = useState(false) const handleTest = async () => { @@ -62,30 +125,39 @@ function ScheduleRow({ schedule, siteId, onMutate }: { schedule: ReportSchedule; return (
-
-
+
+
-
-

+

+

{schedule.channel === 'email' && 'recipients' in schedule.channel_config - ? (schedule.channel_config as { recipients: string[] }).recipients?.[0] + ? (schedule.channel_config as EmailConfig).recipients?.[0] : schedule.channel} {!schedule.enabled && (paused)}

{schedule.frequency} · {schedule.report_type} report + {schedule.last_sent_at && ( + · sent {formatDateTime(new Date(schedule.last_sent_at))} + )}

+ {schedule.last_error && ( +

{schedule.last_error}

+ )}
-
- + -
@@ -93,12 +165,460 @@ function ScheduleRow({ schedule, siteId, onMutate }: { schedule: ReportSchedule; ) } +// ── Channel Grid Picker ────────────────────────────────────────────────────── + +const CHANNELS = ['email', 'slack', 'discord', 'webhook'] as const + +function ChannelPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( +
+ {CHANNELS.map((ch) => ( + + ))} +
+ ) +} + +// ── Shared form label ──────────────────────────────────────────────────────── + +function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) { + return +} + +function FormInput({ id, type = 'text', value, onChange, placeholder }: { id?: string; type?: string; value: string; onChange: (v: string) => void; placeholder?: string }) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className="w-full h-10 px-4 bg-transparent border border-neutral-800 rounded-lg text-sm text-white placeholder:text-neutral-600 focus:outline-none focus:border-brand-orange focus:ring-2 focus:ring-ring/20 transition-colors" + /> + ) +} + +// ── Report Schedule Modal ──────────────────────────────────────────────────── + +function ReportScheduleModal({ + isOpen, + onClose, + siteId, + siteTimezone, + editing, + onSaved, +}: { + isOpen: boolean + onClose: () => void + siteId: string + siteTimezone: string + editing: ReportSchedule | null + onSaved: () => void +}) { + const [saving, setSaving] = useState(false) + const [form, setForm] = useState(() => formFromSchedule(editing, siteTimezone)) + + // Reset form when editing target changes + function formFromSchedule(schedule: ReportSchedule | null, fallbackTz: string) { + if (schedule) { + return { + channel: schedule.channel, + recipients: schedule.channel === 'email' && 'recipients' in schedule.channel_config + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : '', + webhookUrl: schedule.channel !== 'email' && 'url' in schedule.channel_config + ? (schedule.channel_config as WebhookConfig).url + : '', + frequency: schedule.frequency, + reportType: schedule.report_type, + timezone: schedule.timezone || fallbackTz, + sendHour: schedule.send_hour, + sendDay: schedule.send_day ?? 1, + } + } + return { + channel: 'email', + recipients: '', + webhookUrl: '', + frequency: 'weekly', + reportType: 'summary', + timezone: fallbackTz, + sendHour: 9, + sendDay: 1, + } + } + + // Re-init when modal opens with different editing target + const [prevEditing, setPrevEditing] = useState(editing) + if (editing !== prevEditing) { + setPrevEditing(editing) + setForm(formFromSchedule(editing, siteTimezone)) + } + + const updateField = (key: K, value: (typeof form)[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const handleSubmit = async () => { + // Validation + if (form.channel === 'email') { + const emails = form.recipients.split(',').map((r) => r.trim()).filter(Boolean) + if (emails.length === 0) { toast.error('Enter at least one email address'); return } + } else { + if (!form.webhookUrl.trim()) { toast.error('Enter a webhook URL'); return } + } + + setSaving(true) + try { + const channelConfig: EmailConfig | WebhookConfig = + form.channel === 'email' + ? { recipients: form.recipients.split(',').map((r) => r.trim()).filter(Boolean) } + : { url: form.webhookUrl.trim() } + + const payload: CreateReportScheduleRequest = { + channel: form.channel, + channel_config: channelConfig, + frequency: form.frequency, + report_type: form.reportType, + timezone: form.timezone, + send_hour: form.sendHour, + send_day: form.frequency === 'weekly' || form.frequency === 'monthly' ? form.sendDay : undefined, + purpose: 'report', + } + + if (editing) { + await updateReportSchedule(siteId, editing.id, payload) + toast.success('Report schedule updated') + } else { + await createReportSchedule(siteId, payload) + toast.success('Report schedule created') + } + onSaved() + onClose() + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to save schedule') + } finally { + setSaving(false) + } + } + + const webhookPlaceholder = + form.channel === 'slack' ? 'https://hooks.slack.com/services/...' + : form.channel === 'discord' ? 'https://discord.com/api/webhooks/...' + : 'https://example.com/webhook' + + return ( + +
+ {/* Channel */} +
+ Channel + updateField('channel', v)} /> +
+ + {/* Recipients / URL */} + {form.channel === 'email' ? ( +
+ Recipients + updateField('recipients', v)} + placeholder="email@example.com, another@example.com" + /> +

Comma-separated email addresses

+
+ ) : ( +
+ Webhook URL + updateField('webhookUrl', v)} + placeholder={webhookPlaceholder} + /> +
+ )} + + {/* Frequency */} +
+ Frequency + updateField('sendDay', Number(v))} + variant="input" + fullWidth + options={WEEKDAY_NAMES.map((name, i) => ({ value: String(i + 1), label: name }))} + /> +
+ )} + + {/* Day of month (monthly) */} + {form.frequency === 'monthly' && ( +
+ Day of Month + updateField('sendHour', Number(v))} + variant="input" + fullWidth + options={Array.from({ length: 24 }, (_, i) => ({ + value: String(i), + label: formatHour(i), + }))} + /> +
+ + {/* Timezone */} +
+ Timezone + updateField('reportType', v)} + variant="input" + fullWidth + options={[ + { value: 'summary', label: 'Summary' }, + { value: 'pages', label: 'Pages' }, + { value: 'sources', label: 'Sources' }, + { value: 'goals', label: 'Goals' }, + ]} + /> +
+ + {/* Actions */} +
+ + +
+
+
+ ) +} + +// ── Alert Channel Modal ────────────────────────────────────────────────────── + +function AlertChannelModal({ + isOpen, + onClose, + siteId, + siteTimezone, + editing, + onSaved, +}: { + isOpen: boolean + onClose: () => void + siteId: string + siteTimezone: string + editing: ReportSchedule | null + onSaved: () => void +}) { + const [saving, setSaving] = useState(false) + const [form, setForm] = useState(() => formFromAlert(editing)) + + function formFromAlert(schedule: ReportSchedule | null) { + if (schedule) { + return { + channel: schedule.channel, + recipients: schedule.channel === 'email' && 'recipients' in schedule.channel_config + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : '', + webhookUrl: schedule.channel !== 'email' && 'url' in schedule.channel_config + ? (schedule.channel_config as WebhookConfig).url + : '', + } + } + return { channel: 'email', recipients: '', webhookUrl: '' } + } + + const [prevEditing, setPrevEditing] = useState(editing) + if (editing !== prevEditing) { + setPrevEditing(editing) + setForm(formFromAlert(editing)) + } + + const updateField = (key: K, value: (typeof form)[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const handleSubmit = async () => { + if (form.channel === 'email') { + const emails = form.recipients.split(',').map((r) => r.trim()).filter(Boolean) + if (emails.length === 0) { toast.error('Enter at least one email address'); return } + } else { + if (!form.webhookUrl.trim()) { toast.error('Enter a webhook URL'); return } + } + + setSaving(true) + try { + const channelConfig: EmailConfig | WebhookConfig = + form.channel === 'email' + ? { recipients: form.recipients.split(',').map((r) => r.trim()).filter(Boolean) } + : { url: form.webhookUrl.trim() } + + const payload: CreateReportScheduleRequest = { + channel: form.channel, + channel_config: channelConfig, + frequency: 'daily', // Alerts don't have a user-chosen frequency + timezone: siteTimezone, + purpose: 'alert', + } + + if (editing) { + await updateReportSchedule(siteId, editing.id, payload) + toast.success('Alert channel updated') + } else { + await createReportSchedule(siteId, payload) + toast.success('Alert channel created') + } + onSaved() + onClose() + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to save alert channel') + } finally { + setSaving(false) + } + } + + const webhookPlaceholder = + form.channel === 'slack' ? 'https://hooks.slack.com/services/...' + : form.channel === 'discord' ? 'https://discord.com/api/webhooks/...' + : 'https://example.com/webhook' + + return ( + +
+ {/* Channel */} +
+ Channel + updateField('channel', v)} /> +
+ + {/* Recipients / URL */} + {form.channel === 'email' ? ( +
+ Recipients + updateField('recipients', v)} + placeholder="email@example.com, another@example.com" + /> +

Comma-separated email addresses

+
+ ) : ( +
+ Webhook URL + updateField('webhookUrl', v)} + placeholder={webhookPlaceholder} + /> +
+ )} + + {/* Info box */} +
+

+ Alerts are sent automatically when your site goes down or recovers. +

+
+ + {/* Actions */} +
+ + +
+
+
+ ) +} + +// ── Main Tab ───────────────────────────────────────────────────────────────── + export default function SiteReportsTab({ siteId }: { siteId: string }) { + const { data: site } = useSite(siteId) const { data: reports = [], isLoading: reportsLoading, mutate: mutateReports } = useReportSchedules(siteId) const { data: alerts = [], isLoading: alertsLoading, mutate: mutateAlerts } = useAlertSchedules(siteId) + // Report modal state + const [reportModalOpen, setReportModalOpen] = useState(false) + const [editingSchedule, setEditingSchedule] = useState(null) + + // Alert modal state + const [alertModalOpen, setAlertModalOpen] = useState(false) + const [editingAlert, setEditingAlert] = useState(null) + + const siteTimezone = site?.timezone || 'UTC' const loading = reportsLoading || alertsLoading + const openNewReport = () => { setEditingSchedule(null); setReportModalOpen(true) } + const openEditReport = (schedule: ReportSchedule) => { setEditingSchedule(schedule); setReportModalOpen(true) } + const openNewAlert = () => { setEditingAlert(null); setAlertModalOpen(true) } + const openEditAlert = (schedule: ReportSchedule) => { setEditingAlert(schedule); setAlertModalOpen(true) } + if (loading) return
return ( @@ -110,19 +630,17 @@ export default function SiteReportsTab({ siteId }: { siteId: string }) {

Scheduled Reports

Automated analytics summaries via email or webhook.

- - - +
{reports.length === 0 ? (

No scheduled reports yet.

) : (
- {reports.map(r => ( - mutateReports()} /> + {reports.map((r) => ( + mutateReports()} onEdit={openEditReport} /> ))}
)} @@ -135,23 +653,45 @@ export default function SiteReportsTab({ siteId }: { siteId: string }) {

Alert Channels

Get notified when uptime monitors go down.

- - - +
{alerts.length === 0 ? (

No alert channels configured.

) : (
- {alerts.map(a => ( - mutateAlerts()} /> + {alerts.map((a) => ( + mutateAlerts()} onEdit={openEditAlert} /> ))}
)} + + {/* Report Schedule Modal */} + {reportModalOpen && ( + setReportModalOpen(false)} + siteId={siteId} + siteTimezone={siteTimezone} + editing={editingSchedule} + onSaved={() => mutateReports()} + /> + )} + + {/* Alert Channel Modal */} + {alertModalOpen && ( + setAlertModalOpen(false)} + siteId={siteId} + siteTimezone={siteTimezone} + editing={editingAlert} + onSaved={() => mutateAlerts()} + /> + )} ) }