feat(webui): add aggregate Campaign reports

This commit is contained in:
2026-07-22 08:44:15 +02:00
parent 06125cc0e8
commit fc36aee6c0
4 changed files with 348 additions and 0 deletions

View File

@@ -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<CampaignListItem[]> {
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
@@ -468,6 +515,13 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
return response.campaigns ?? response.items ?? response.results ?? [];
}
export async function listAggregateReportCampaigns(
settings: ApiSettings)
: Promise<AggregateReportCampaignListItem[]> {
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<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
}
export async function getAggregateCampaignReport(
settings: ApiSettings,
campaignId: string)
: Promise<AggregateCampaignReport> {
return apiFetch<AggregateCampaignReport>(
settings,
`/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}`
);
}
export async function downloadCampaignJobsCsv(
settings: ApiSettings,
campaignId: string,

View File

@@ -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<AggregateReportCampaignListItem[]>([]);
const [report, setReport] = useState<AggregateCampaignReport | null>(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<DataGridColumn<AggregateReportCampaignListItem>[]>(() => [
{
id: "campaign",
header: "i18n:govoplan-campaign.campaign.69390e16",
width: "minmax(260px, 1fr)",
sortable: true,
filterable: true,
sticky: "start",
render: (campaign) => (
<div>
<strong>{campaign.name}</strong>
</div>
),
value: (campaign) => campaign.name
},
{
id: "status",
header: "i18n:govoplan-campaign.status.bae7d5be",
width: 170,
sortable: true,
filterable: true,
render: (campaign) => <StatusBadge status={campaign.status} />,
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) => (
<TableActionGroup actions={[{
id: "view-aggregate-report",
label: "View aggregate report",
icon: <Eye aria-hidden="true" />,
variant: campaign.id === selectedFromUrl ? "primary" : "secondary",
disabled: campaign.id === selectedFromUrl,
disabledReason: campaign.id === selectedFromUrl ? "This report is already selected." : undefined,
onClick: () => selectCampaign(campaign.id)
}]} />
)
}
], [selectCampaign, selectedFromUrl]);
const threshold = report?.privacy.small_cell_threshold ?? 5;
const outcomes = report?.outcomes;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={listLoading || reportLoading}>i18n:govoplan-campaign.reports.88bc3fe3</PageTitle>
<p className="muted">Privacy-protected Campaign outcomes without recipient or delivery diagnostics.</p>
</div>
<div className="button-row compact-actions">
<Button
onClick={() => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()])}
disabled={listLoading || reportLoading}
>
<RefreshCw size={16} aria-hidden="true" /> Refresh
</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<Card title="Campaign reports available to you">
<LoadingFrame loading={listLoading} label="Loading Campaign reports…">
<DataGrid
id="campaign-aggregate-report-list"
rows={campaigns}
columns={columns}
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
emptyText="No aggregate Campaign reports are available to you."
/>
</LoadingFrame>
</Card>
<LoadingFrame loading={reportLoading} label="Loading aggregate Campaign report…">
{report && outcomes && (
<>
<Card title={report.campaign.name}>
<dl className="detail-list">
<div><dt>Status</dt><dd><StatusBadge status={report.campaign.status} /></dd></div>
<div><dt>Completion</dt><dd><StatusBadge status={report.completion_state} /></dd></div>
<div><dt>Version</dt><dd>{report.version_number ?? "—"}</dd></div>
<div><dt>Generated</dt><dd>{formatDateTime(report.generated_at)}</dd></div>
</dl>
</Card>
<div className="dashboard-grid">
<MetricCard label="SMTP accepted" value={countValue(outcomes.smtp_accepted)} tone="good" />
<MetricCard label="Failed" value={countValue(outcomes.failed)} tone="danger" />
<MetricCard label="Outcome unknown" value={countValue(outcomes.outcome_unknown)} tone="warning" />
<MetricCard label="Queued or active" value={countValue(outcomes.queued_or_active)} tone="info" />
<MetricCard label="Not attempted" value={countValue(outcomes.not_attempted)} />
<MetricCard label="Cancelled" value={countValue(outcomes.cancelled)} />
<MetricCard label="Excluded" value={countValue(outcomes.excluded)} />
</div>
<Card title="Population and exclusions">
<dl className="detail-list">
<div><dt>Report denominator</dt><dd>{countValue(report.population.denominator)}</dd></div>
<div><dt>Inactive source entries</dt><dd>{countValue(report.population.inactive_source_entries)}</dd></div>
<div><dt>Excluded or blocked jobs</dt><dd>{countValue(report.population.excluded_or_blocked_jobs)}</dd></div>
</dl>
<p className="muted">{report.population.denominator_definition}</p>
</Card>
<Card title="Delivery activity range">
<dl className="detail-list">
<div><dt>First activity</dt><dd>{report.time_range.suppressed ? "Suppressed" : formatDateTime(report.time_range.first_activity_at ?? undefined)}</dd></div>
<div><dt>Last activity</dt><dd>{report.time_range.suppressed ? "Suppressed" : formatDateTime(report.time_range.last_activity_at ?? undefined)}</dd></div>
</dl>
</Card>
<Card title="Privacy boundary">
<p>{report.privacy.rule}</p>
<p className="muted">
Small-cell threshold: {threshold}. Suppressed values cannot be opened, filtered, exported, or inspected from this view.
</p>
</Card>
</>
)}
</LoadingFrame>
</div>
);
}
function countValue(count: AggregateReportCount): string | number {
return count.suppressed ? "Suppressed" : count.value ?? "—";
}

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const api = readFileSync("src/api/campaigns.ts", "utf8");
const page = readFileSync("src/features/reports/AggregateReportsPage.tsx", "utf8");
assert(api.includes('"/api/v1/campaigns/aggregate-reports"'), "the report list uses the safe aggregate collection");
assert(api.includes("/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}"), "the selected report uses the safe aggregate projection");
assert(page.includes("listAggregateReportCampaigns"), "the page loads only the safe Campaign selector projection");
assert(page.includes("getAggregateCampaignReport"), "the page loads only the safe aggregate report projection");
assert(!page.includes("getCampaignReport"), "the aggregate page cannot load the recipient-aware full report");
assert(!page.includes("getCampaignJobs"), "the aggregate page cannot cache recipient jobs");
assert(!page.includes("getCampaignJobDetail"), "the aggregate page has no detail route");
assert(!page.includes("downloadCampaignJobsCsv"), "the aggregate page has no export path");
assert(!page.includes("localStorage"), "the aggregate page does not persist report data in local storage");
assert(!page.includes("sessionStorage"), "the aggregate page does not persist report data in session storage");
assert(page.includes("TableActionGroup"), "campaign selection uses the central icon-only table action group");
assert(page.includes("disabled: campaign.id === selectedFromUrl"), "the selected row action stays visible and disabled");
console.log("aggregate report UI structure checks passed");

View File

@@ -0,0 +1,30 @@
{
"extends": "../../govoplan-core/webui/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": [
"../../govoplan-core/webui/src/index.ts"
],
"@govoplan/core-webui/app": [
"../../govoplan-core/webui/src/app.ts"
],
"react": [
"../../govoplan-core/webui/node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
]
},
"typeRoots": [
"../../govoplan-core/webui/node_modules/@types"
]
},
"include": [],
"files": [
"../../govoplan-core/webui/src/vite-env.d.ts",
"src/features/reports/AggregateReportsPage.tsx"
]
}