feat(webui): add aggregate Campaign reports
This commit is contained in:
@@ -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,
|
||||
|
||||
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 ?? "—";
|
||||
}
|
||||
Reference in New Issue
Block a user