diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 0244837..0ce6879 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -458,6 +458,53 @@ export type CampaignReportEmailPayload = { dry_run?: boolean; }; +export type AggregateReportCount = { + value: number | null; + suppressed: boolean; +}; + +export type AggregateReportCampaign = { + id: string; + name: string; + status: string; +}; + +export type AggregateReportCampaignListItem = AggregateReportCampaign & { + updated_at: string; +}; + +export type AggregateCampaignReport = { + generated_at: string; + campaign: AggregateReportCampaign; + version_number: number | null; + completion_state: "not_started" | "in_progress" | "completed" | "partially_completed" | "incomplete" | "outcome_unknown" | "suppressed"; + population: { + denominator: AggregateReportCount; + denominator_definition: string; + inactive_source_entries: AggregateReportCount; + excluded_or_blocked_jobs: AggregateReportCount; + }; + outcomes: { + smtp_accepted: AggregateReportCount; + failed: AggregateReportCount; + outcome_unknown: AggregateReportCount; + queued_or_active: AggregateReportCount; + cancelled: AggregateReportCount; + excluded: AggregateReportCount; + not_attempted: AggregateReportCount; + }; + time_range: { + first_activity_at: string | null; + last_activity_at: string | null; + suppressed: boolean; + }; + privacy: { + small_cell_threshold: number; + suppression_applied: boolean; + rule: string; + }; +}; + export async function listCampaigns(settings: ApiSettings): Promise { const response = await apiFetch(settings, "/api/v1/campaigns"); @@ -468,6 +515,13 @@ export async function listCampaigns(settings: ApiSettings): Promise { + const response = await apiFetch<{campaigns: AggregateReportCampaignListItem[]}>(settings, "/api/v1/campaigns/aggregate-reports"); + return response.campaigns; +} + export async function listCampaignsDelta( settings: ApiSettings, options: {since?: string | null;limit?: number;} = {}) @@ -833,6 +887,16 @@ versionId?: string) return apiFetch(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`); } +export async function getAggregateCampaignReport( +settings: ApiSettings, +campaignId: string) +: Promise { + return apiFetch( + settings, + `/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}` + ); +} + export async function downloadCampaignJobsCsv( settings: ApiSettings, campaignId: string, diff --git a/webui/src/features/reports/AggregateReportsPage.tsx b/webui/src/features/reports/AggregateReportsPage.tsx new file mode 100644 index 0000000..fff3cc0 --- /dev/null +++ b/webui/src/features/reports/AggregateReportsPage.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Eye, RefreshCw } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; +import { + Button, + Card, + DataGrid, + DismissibleAlert, + LoadingFrame, + MetricCard, + PageTitle, + StatusBadge, + TableActionGroup, + formatDateTime, + type ApiSettings, + type DataGridColumn +} from "@govoplan/core-webui"; +import { + getAggregateCampaignReport, + listAggregateReportCampaigns, + type AggregateCampaignReport, + type AggregateReportCampaignListItem, + type AggregateReportCount +} from "../../api/campaigns"; + +export default function AggregateReportsPage({ settings }: {settings: ApiSettings;}) { + const [searchParams, setSearchParams] = useSearchParams(); + const selectedFromUrl = searchParams.get("campaign") ?? ""; + const [campaigns, setCampaigns] = useState([]); + const [report, setReport] = useState(null); + const [listLoading, setListLoading] = useState(true); + const [reportLoading, setReportLoading] = useState(false); + const [error, setError] = useState(""); + const reportRequest = useRef(0); + + const selectCampaign = useCallback((campaignId: string) => { + setSearchParams(campaignId ? { campaign: campaignId } : {}, { replace: true }); + }, [setSearchParams]); + + const loadCampaigns = useCallback(async () => { + setListLoading(true); + setError(""); + try { + const rows = await listAggregateReportCampaigns(settings); + setCampaigns(rows); + const requested = selectedFromUrl && rows.some((row) => row.id === selectedFromUrl) + ? selectedFromUrl + : rows[0]?.id ?? ""; + if (requested !== selectedFromUrl) selectCampaign(requested); + if (!requested) setReport(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setCampaigns([]); + setReport(null); + } finally { + setListLoading(false); + } + }, [settings, selectedFromUrl, selectCampaign]); + + const loadReport = useCallback(async (campaignId: string) => { + if (!campaignId) { + setReport(null); + return; + } + const requestId = ++reportRequest.current; + setReportLoading(true); + setError(""); + try { + const next = await getAggregateCampaignReport(settings, campaignId); + if (requestId === reportRequest.current) setReport(next); + } catch (err) { + if (requestId === reportRequest.current) { + setError(err instanceof Error ? err.message : String(err)); + setReport(null); + } + } finally { + if (requestId === reportRequest.current) setReportLoading(false); + } + }, [settings]); + + useEffect(() => { + void loadCampaigns(); + }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + + useEffect(() => { + if (!listLoading && selectedFromUrl && campaigns.some((row) => row.id === selectedFromUrl)) { + void loadReport(selectedFromUrl); + } + }, [campaigns, listLoading, loadReport, selectedFromUrl]); + + const columns = useMemo[]>(() => [ + { + id: "campaign", + header: "i18n:govoplan-campaign.campaign.69390e16", + width: "minmax(260px, 1fr)", + sortable: true, + filterable: true, + sticky: "start", + render: (campaign) => ( +
+ {campaign.name} +
+ ), + value: (campaign) => campaign.name + }, + { + id: "status", + header: "i18n:govoplan-campaign.status.bae7d5be", + width: 170, + sortable: true, + filterable: true, + render: (campaign) => , + value: (campaign) => campaign.status + }, + { + id: "updated", + header: "i18n:govoplan-campaign.updated.f2f8570d", + width: 190, + sortable: true, + filterable: true, + filterType: "date", + render: (campaign) => formatDateTime(campaign.updated_at), + value: (campaign) => campaign.updated_at + }, + { + id: "actions", + header: "i18n:govoplan-campaign.actions.c3cd636a", + width: 72, + sticky: "end", + align: "right", + render: (campaign) => ( +