feat(webui): add aggregate Campaign reports
This commit is contained in:
@@ -458,6 +458,53 @@ export type CampaignReportEmailPayload = {
|
|||||||
dry_run?: boolean;
|
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[]> {
|
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
|
||||||
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
|
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 ?? [];
|
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(
|
export async function listCampaignsDelta(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
options: {since?: string | null;limit?: number;} = {})
|
options: {since?: string | null;limit?: number;} = {})
|
||||||
@@ -833,6 +887,16 @@ versionId?: string)
|
|||||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
|
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(
|
export async function downloadCampaignJobsCsv(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
233
webui/src/features/reports/AggregateReportsPage.tsx
Normal file
233
webui/src/features/reports/AggregateReportsPage.tsx
Normal 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 ?? "—";
|
||||||
|
}
|
||||||
21
webui/tests/aggregate-report-ui-structure.test.mjs
Normal file
21
webui/tests/aggregate-report-ui-structure.test.mjs
Normal 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");
|
||||||
30
webui/tsconfig.aggregate-report-tests.json
Normal file
30
webui/tsconfig.aggregate-report-tests.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user