275 lines
12 KiB
TypeScript
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];
|
|
}
|