feat(campaign): filter reports from outcome counts
This commit is contained in:
@@ -28,6 +28,13 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||
import { emptyCampaignJobsResponse } from "./utils/jobDeltas";
|
||||
import type { CampaignJobSortColumn } from "./utils/jobListQuery";
|
||||
import {
|
||||
DEFAULT_REPORT_GRID_SORT,
|
||||
activeReportGridShortcut,
|
||||
reportGridQueriesEqual,
|
||||
toggleReportGridShortcut,
|
||||
type ReportGridShortcutId
|
||||
} from "./utils/reportGridShortcuts";
|
||||
|
||||
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_queued",
|
||||
@@ -70,7 +77,6 @@ const QUEUE_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"cancelled"].
|
||||
map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
const DEFAULT_JOB_GRID_SORT = { columnId: "number", direction: "asc" as const };
|
||||
const JOB_GRID_QUERY_DELAY_MS = 300;
|
||||
|
||||
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
|
||||
@@ -90,7 +96,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [initialGridFilters] = useState<Record<string, string | string[]>>(() => initialReportGridFilters());
|
||||
const initialGridQuery = useMemo<DataGridQueryState>(() => ({
|
||||
sort: DEFAULT_JOB_GRID_SORT,
|
||||
sort: DEFAULT_REPORT_GRID_SORT,
|
||||
filters: serializeInitialGridFilters(initialGridFilters)
|
||||
}), [initialGridFilters]);
|
||||
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(initialGridQuery);
|
||||
@@ -110,16 +116,45 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
setAppliedQuery(query.trim());
|
||||
setAppliedJobGridQuery((current) => dataGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery);
|
||||
setAppliedJobGridQuery((current) => reportGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery);
|
||||
setPage(1);
|
||||
}, JOB_GRID_QUERY_DELAY_MS);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, jobGridQuery]);
|
||||
|
||||
const handleJobGridQuery = useCallback((next: DataGridQueryState) => {
|
||||
setJobGridQuery((current) => dataGridQueriesEqual(current, next) ? current : next);
|
||||
setJobGridQuery((current) => reportGridQueriesEqual(current, next) ? current : next);
|
||||
}, []);
|
||||
|
||||
const activeJobGridShortcut = useMemo(
|
||||
() => query.trim() ? null : activeReportGridShortcut(jobGridQuery),
|
||||
[jobGridQuery, query]
|
||||
);
|
||||
|
||||
const applyJobGridShortcut = useCallback((shortcutId: ReportGridShortcutId) => {
|
||||
const next = toggleReportGridShortcut(jobGridQuery, shortcutId);
|
||||
setQuery("");
|
||||
setAppliedQuery("");
|
||||
setJobGridQuery(next);
|
||||
setAppliedJobGridQuery(next);
|
||||
setPage(1);
|
||||
}, [jobGridQuery]);
|
||||
|
||||
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
|
||||
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
|
||||
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
|
||||
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" },
|
||||
{ label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" },
|
||||
{ label: "i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19", value: cards?.skipped ?? jobs.counts.send?.skipped ?? 0, shortcutId: "smtp_skipped" },
|
||||
{ label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" }
|
||||
];
|
||||
const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||
{ label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" },
|
||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" },
|
||||
{ label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" }
|
||||
];
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
if (!campaignId) return;
|
||||
const requestId = ++jobsRequestRef.current;
|
||||
@@ -383,20 +418,36 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19</dt><dd>{cards?.skipped ?? jobs.counts.send?.skipped ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||
const active = activeJobGridShortcut === shortcutId;
|
||||
return (
|
||||
<div key={shortcutId}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
|
||||
{value}
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_skipped.5a97b542</dt><dd>{cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0}</dd></div>
|
||||
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||
const active = activeJobGridShortcut === shortcutId;
|
||||
return (
|
||||
<div key={shortcutId}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
|
||||
{value}
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
||||
@@ -435,7 +486,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
|
||||
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5"
|
||||
initialFilters={initialGridFilters}
|
||||
initialSort={DEFAULT_JOB_GRID_SORT}
|
||||
initialSort={DEFAULT_REPORT_GRID_SORT}
|
||||
query={jobGridQuery}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
page,
|
||||
@@ -589,16 +641,6 @@ function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
|
||||
return "number";
|
||||
}
|
||||
|
||||
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
|
||||
for (const key of keys) {
|
||||
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function retryableFailedStatus(status: string): boolean {
|
||||
return status === "failed_temporary" || status === "failed_permanent";
|
||||
}
|
||||
|
||||
85
webui/src/features/campaigns/utils/reportGridShortcuts.ts
Normal file
85
webui/src/features/campaigns/utils/reportGridShortcuts.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type ReportGridQueryState = {
|
||||
sort: { columnId: string; direction: "asc" | "desc" } | null;
|
||||
filters: Record<string, string>;
|
||||
};
|
||||
|
||||
export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" as const };
|
||||
|
||||
export type ReportGridShortcutId =
|
||||
| "all"
|
||||
| "smtp_accepted"
|
||||
| "failed"
|
||||
| "outcome_unknown"
|
||||
| "not_attempted"
|
||||
| "smtp_skipped"
|
||||
| "cancelled"
|
||||
| "imap_appended"
|
||||
| "imap_failed"
|
||||
| "imap_skipped";
|
||||
|
||||
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
|
||||
all: {},
|
||||
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
|
||||
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
|
||||
outcome_unknown: { send: listFilter(["outcome_unknown"]) },
|
||||
not_attempted: { send: listFilter(["not_queued"]) },
|
||||
smtp_skipped: { send: listFilter(["skipped"]) },
|
||||
cancelled: { send: listFilter(["cancelled"]) },
|
||||
imap_appended: { imap: listFilter(["appended"]) },
|
||||
imap_failed: { imap: listFilter(["failed"]) },
|
||||
imap_skipped: { imap: listFilter(["skipped"]) }
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the complete grid query for a count shortcut. Shortcuts are exact
|
||||
* report views, so applying one intentionally clears every unrelated filter
|
||||
* and restores the stable report ordering.
|
||||
*/
|
||||
export function reportGridQueryForShortcut(shortcutId: ReportGridShortcutId): ReportGridQueryState {
|
||||
return {
|
||||
sort: { ...DEFAULT_REPORT_GRID_SORT },
|
||||
filters: { ...REPORT_GRID_SHORTCUT_FILTERS[shortcutId] }
|
||||
};
|
||||
}
|
||||
|
||||
/** Applying an already selected outcome shortcut returns to the unfiltered report. */
|
||||
export function toggleReportGridShortcut(
|
||||
current: ReportGridQueryState,
|
||||
shortcutId: ReportGridShortcutId
|
||||
): ReportGridQueryState {
|
||||
const target = reportGridQueryForShortcut(shortcutId);
|
||||
if (shortcutId !== "all" && reportGridFiltersEqual(current.filters, target.filters)) {
|
||||
return reportGridQueryForShortcut("all");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function activeReportGridShortcut(query: ReportGridQueryState): ReportGridShortcutId | null {
|
||||
const shortcutIds = Object.keys(REPORT_GRID_SHORTCUT_FILTERS) as ReportGridShortcutId[];
|
||||
return shortcutIds.find((shortcutId) => reportGridFiltersEqual(
|
||||
query.filters,
|
||||
REPORT_GRID_SHORTCUT_FILTERS[shortcutId]
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
export function reportGridQueriesEqual(left: ReportGridQueryState, right: ReportGridQueryState): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
|
||||
for (const key of keys) {
|
||||
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function listFilter(values: string[]): string {
|
||||
return `list:${JSON.stringify(values)}`;
|
||||
}
|
||||
|
||||
function reportGridFiltersEqual(left: Record<string, string>, right: Record<string, string>): boolean {
|
||||
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
||||
for (const key of keys) {
|
||||
if ((left[key] ?? "") !== (right[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user