From 31aff9555259edead68906e88aadb170a8af929e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 14:31:47 +0100 Subject: [PATCH] feat: add Reports tab to site settings with schedule CRUD --- app/sites/[id]/settings/page.tsx | 411 ++++++++++++++++++++++++++++++- 1 file changed, 409 insertions(+), 2 deletions(-) diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index c17945d..e21ca00 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' +import { listReportSchedules, createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons' @@ -25,6 +26,7 @@ import { AlertTriangleIcon, ZapIcon, } from '@ciphera-net/ui' +import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play } from '@phosphor-icons/react' const TIMEZONES = [ 'UTC', @@ -54,7 +56,7 @@ export default function SiteSettingsPage() { const [site, setSite] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) - const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals'>('general') + const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general') const [formData, setFormData] = useState({ name: '', @@ -91,6 +93,22 @@ export default function SiteSettingsPage() { const [goalSaving, setGoalSaving] = useState(false) const initialFormRef = useRef('') + // Report schedules state + const [reportSchedules, setReportSchedules] = useState([]) + const [reportLoading, setReportLoading] = useState(false) + const [reportModalOpen, setReportModalOpen] = useState(false) + const [editingSchedule, setEditingSchedule] = useState(null) + const [reportSaving, setReportSaving] = useState(false) + const [reportTesting, setReportTesting] = useState(null) + const [reportForm, setReportForm] = useState({ + channel: 'email' as string, + recipients: '', + webhookUrl: '', + frequency: 'weekly' as string, + reportType: 'summary' as string, + timezone: '', + }) + useEffect(() => { loadSite() loadSubscription() @@ -102,6 +120,12 @@ export default function SiteSettingsPage() { } }, [activeTab, siteId]) + useEffect(() => { + if (activeTab === 'reports' && siteId) { + loadReportSchedules() + } + }, [activeTab, siteId]) + const loadSubscription = async () => { try { setSubscriptionLoadFailed(false) @@ -191,6 +215,150 @@ export default function SiteSettingsPage() { } } + const loadReportSchedules = async () => { + try { + setReportLoading(true) + const data = await listReportSchedules(siteId) + setReportSchedules(data) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to load report schedules') + } finally { + setReportLoading(false) + } + } + + const resetReportForm = () => { + setReportForm({ + channel: 'email', + recipients: '', + webhookUrl: '', + frequency: 'weekly', + reportType: 'summary', + timezone: site?.timezone || '', + }) + } + + const openEditSchedule = (schedule: ReportSchedule) => { + setEditingSchedule(schedule) + const isEmail = schedule.channel === 'email' + setReportForm({ + channel: schedule.channel, + recipients: isEmail ? (schedule.channel_config as EmailConfig).recipients.join(', ') : '', + webhookUrl: !isEmail ? (schedule.channel_config as WebhookConfig).url : '', + frequency: schedule.frequency, + reportType: schedule.report_type, + timezone: schedule.timezone || site?.timezone || '', + }) + setReportModalOpen(true) + } + + const handleReportSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + let channelConfig: EmailConfig | WebhookConfig + if (reportForm.channel === 'email') { + const recipients = reportForm.recipients.split(',').map(r => r.trim()).filter(r => r.length > 0) + if (recipients.length === 0) { + toast.error('At least one recipient email is required') + return + } + channelConfig = { recipients } + } else { + if (!reportForm.webhookUrl.trim()) { + toast.error('Webhook URL is required') + return + } + channelConfig = { url: reportForm.webhookUrl.trim() } + } + + const payload: CreateReportScheduleRequest = { + channel: reportForm.channel, + channel_config: channelConfig, + frequency: reportForm.frequency, + timezone: reportForm.timezone || undefined, + report_type: reportForm.reportType, + } + + setReportSaving(true) + try { + if (editingSchedule) { + await updateReportSchedule(siteId, editingSchedule.id, payload) + toast.success('Report schedule updated') + } else { + await createReportSchedule(siteId, payload) + toast.success('Report schedule created') + } + setReportModalOpen(false) + loadReportSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule') + } finally { + setReportSaving(false) + } + } + + const handleReportDelete = async (schedule: ReportSchedule) => { + if (!confirm('Delete this report schedule?')) return + try { + await deleteReportSchedule(siteId, schedule.id) + toast.success('Report schedule deleted') + loadReportSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule') + } + } + + const handleReportToggle = async (schedule: ReportSchedule) => { + try { + await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled }) + toast.success(schedule.enabled ? 'Report paused' : 'Report enabled') + loadReportSchedules() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule') + } + } + + const handleReportTest = async (schedule: ReportSchedule) => { + setReportTesting(schedule.id) + try { + await testReportSchedule(siteId, schedule.id) + toast.success('Test report sent successfully') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to send test report') + } finally { + setReportTesting(null) + } + } + + const getChannelLabel = (channel: string) => { + switch (channel) { + case 'email': return 'Email' + case 'slack': return 'Slack' + case 'discord': return 'Discord' + case 'webhook': return 'Webhook' + default: return channel + } + } + + const getFrequencyLabel = (frequency: string) => { + switch (frequency) { + case 'daily': return 'Daily' + case 'weekly': return 'Weekly' + case 'monthly': return 'Monthly' + default: return frequency + } + } + + const getReportTypeLabel = (type: string) => { + switch (type) { + case 'summary': return 'Summary' + case 'pages': return 'Pages' + case 'sources': return 'Sources' + case 'goals': return 'Goals' + default: return type + } + } + const openAddGoal = () => { setEditingGoal(null) setGoalForm({ name: '', event_name: '' }) @@ -389,7 +557,7 @@ export default function SiteSettingsPage() {
@@ -476,6 +644,19 @@ export default function SiteSettingsPage() { Goals & Events + {/* Content Area */} @@ -1124,6 +1305,132 @@ export default function SiteSettingsPage() { )}
)} + + {activeTab === 'reports' && ( +
+
+
+

Scheduled Reports

+

Automatically deliver analytics reports via email or webhooks.

+
+ {canEdit && ( + + )} +
+ + {reportLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : reportSchedules.length === 0 ? ( +
+ No scheduled reports yet. Add a report to automatically receive analytics summaries. +
+ ) : ( +
+ {reportSchedules.map((schedule) => ( +
+
+
+
+ {schedule.channel === 'email' ? ( + + ) : ( + + )} +
+
+
+ + {getChannelLabel(schedule.channel)} + + + {getFrequencyLabel(schedule.frequency)} + + + {getReportTypeLabel(schedule.report_type)} + +
+

+ {schedule.channel === 'email' + ? (schedule.channel_config as EmailConfig).recipients.join(', ') + : (schedule.channel_config as WebhookConfig).url} +

+
+ + Last sent: {schedule.last_sent_at + ? new Date(schedule.last_sent_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) + : 'Never'} + +
+ {schedule.last_error && ( +

+ Error: {schedule.last_error} +

+ )} +
+
+ + {canEdit && ( +
+ + + + +
+ )} +
+
+ ))} +
+ )} +
+ )}
@@ -1177,6 +1484,106 @@ export default function SiteSettingsPage() { + setReportModalOpen(false)} + title={editingSchedule ? 'Edit report schedule' : 'Add report schedule'} + > +
+
+ +
+ {(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => ( + + ))} +
+
+ + {reportForm.channel === 'email' ? ( +
+ + setReportForm({ ...reportForm, recipients: e.target.value })} + placeholder="email1@example.com, email2@example.com" + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +

Comma-separated email addresses.

+
+ ) : ( +
+ + setReportForm({ ...reportForm, webhookUrl: e.target.value })} + placeholder="https://hooks.example.com/..." + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" + required + /> +
+ )} + +
+ + setReportForm({ ...reportForm, reportType: v })} + options={[ + { value: 'summary', label: 'Summary' }, + { value: 'pages', label: 'Pages' }, + { value: 'sources', label: 'Sources' }, + { value: 'goals', label: 'Goals' }, + ]} + variant="input" + fullWidth + align="left" + /> +
+ +
+ + +
+
+
+ setShowVerificationModal(false)}