Files
govoplan-campaign/webui/src/features/reports/AggregateReportsPage.tsx
T

275 lines
12 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, RefreshCw } from "lucide-react";
import { useSearchParams } from "react-router";
import {
Button,
Card,
DataGrid,
DismissibleAlert,
LoadingFrame,
MetricCard,
PageScrollViewport,
PageTitle,
StatusBadge,
TableActionGroup,
formatDateTime,
i18nMessage,
type ApiSettings,
type DataGridColumn,
type DataGridListOption
} from "@govoplan/core-webui";
import {
getAggregateCampaignReport,
listAggregateReportCampaigns,
type AggregateCampaignReport,
type AggregateReportCampaignListItem,
type AggregateReportCount
} from "../../api/campaigns";
const CAMPAIGN_STATUS_OPTIONS: DataGridListOption[] = [
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
{ value: "validated", label: "i18n:govoplan-campaign.valid.a4aefa35" },
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
{ value: "ready_to_queue", label: "i18n:govoplan-campaign.ready.20c7c552" },
{ value: "queued", label: "i18n:govoplan-campaign.queued.6a599877" },
{ value: "sending", label: "i18n:govoplan-campaign.sending.cf765512" },
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
{ value: "partially_completed", label: "i18n:govoplan-campaign.partially_completed.760bc5e6" },
{ value: "outcome_unknown", label: "i18n:govoplan-campaign.outcome_unknown.6e929fca" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "cancelled", label: "i18n:govoplan-campaign.cancelled.a1bf92ef" },
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }
];
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)",
maxWidth: 680,
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,
columnType: "from-list",
list: { options: CAMPAIGN_STATUS_OPTIONS, display: "pill" },
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: "i18n:govoplan-campaign.view_aggregate_report.94b42124",
icon: <Eye aria-hidden="true" />,
variant: campaign.id === selectedFromUrl ? "primary" : "secondary",
disabled: campaign.id === selectedFromUrl,
disabledReason: campaign.id === selectedFromUrl ? "i18n:govoplan-campaign.this_report_is_already_selected_.983c5be4" : undefined,
onClick: () => selectCampaign(campaign.id)
}]} />
)
}
], [selectCampaign, selectedFromUrl]);
const threshold = report?.privacy.small_cell_threshold ?? 5;
const outcomes = report?.outcomes;
return (
<PageScrollViewport className="aggregate-reports-page">
<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">i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d</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" /> i18n:govoplan-campaign.refresh.56e3badc
</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
<DataGrid
id="campaign-aggregate-report-list"
rows={campaigns}
columns={columns}
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_aggregate_campaign_reports_are_available_to_y.dafb2781"
/>
</LoadingFrame>
</Card>
<LoadingFrame loading={reportLoading} label="i18n:govoplan-campaign.loading_aggregate_campaign_report_.72f2f091">
{report && outcomes && (
<>
<Card title={report.campaign.name}>
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={report.campaign.status} /></dd></div>
<div><dt>i18n:govoplan-campaign.completion.2ff25568</dt><dd><StatusBadge status={report.completion_state} label={aggregateCompletionLabel(report.completion_state)} /></dd></div>
<div><dt>i18n:govoplan-campaign.version.2da600bf</dt><dd>{report.version_number ?? "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(report.generated_at)}</dd></div>
</dl>
</Card>
{/* Aggregate outcome cards are deliberately not filter shortcuts: each subgroup
would require a separately suppressed aggregate response from the server. */}
<div className="dashboard-grid">
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
<MetricCard label="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
<MetricCard label="Both channels accepted" value={countValue(outcomes.delivered)} tone="good" />
<MetricCard label="Partially accepted" value={countValue(outcomes.partially_accepted)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={countValue(outcomes.outcome_unknown)} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.queued_or_active.afdcd7da" value={countValue(outcomes.queued_or_active)} tone="info" />
<MetricCard label="i18n:govoplan-campaign.not_attempted.e1be3c69" value={countValue(outcomes.not_attempted)} />
<MetricCard label="i18n:govoplan-campaign.cancelled.a1bf92ef" value={countValue(outcomes.cancelled)} />
<MetricCard label="i18n:govoplan-campaign.excluded.9804952b" value={countValue(outcomes.excluded)} />
</div>
<Card title="i18n:govoplan-campaign.population_and_exclusions.cdff62ad">
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.report_denominator.d089074a</dt><dd>{countValue(report.population.denominator)}</dd></div>
<div><dt>i18n:govoplan-campaign.inactive_source_entries.88fa0cf8</dt><dd>{countValue(report.population.inactive_source_entries)}</dd></div>
<div><dt>i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47</dt><dd>{countValue(report.population.excluded_or_blocked_jobs)}</dd></div>
</dl>
<p className="muted">i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc</p>
</Card>
<Card title="i18n:govoplan-campaign.delivery_activity_range.0d1b62ec">
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.first_activity.3fe36ef4</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.first_activity_at ?? undefined)}</dd></div>
<div><dt>i18n:govoplan-campaign.last_activity.1c73b806</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.last_activity_at ?? undefined)}</dd></div>
</dl>
</Card>
<Card title="i18n:govoplan-campaign.privacy_boundary.d37e12bc">
<p>i18n:govoplan-campaign.positive_counts_below_the_threshold_are_hidden_a.1dc97093</p>
<p className="muted">
{i18nMessage("i18n:govoplan-campaign.small_cell_threshold_value0_suppressed_values_ca.d73f87a6", { value0: threshold })}
</p>
</Card>
</>
)}
</LoadingFrame>
</div>
</PageScrollViewport>
);
}
function countValue(count: AggregateReportCount): string | number {
return count.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : count.value ?? "—";
}
function aggregateCompletionLabel(state: AggregateCampaignReport["completion_state"]): string {
const labels: Record<AggregateCampaignReport["completion_state"], string> = {
not_started: "i18n:govoplan-campaign.not_started.db2c45d5",
in_progress: "i18n:govoplan-campaign.in_progress.b6bd42e4",
completed: "i18n:govoplan-campaign.completed.1798b3ba",
partially_completed: "i18n:govoplan-campaign.partially_completed.760bc5e6",
incomplete: "i18n:govoplan-campaign.incomplete.387fd1bb",
outcome_unknown: "i18n:govoplan-campaign.outcome_unknown.6e929fca",
suppressed: "i18n:govoplan-campaign.suppressed.fce88db5"
};
return labels[state];
}