feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -21,7 +21,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"scripts": {
|
||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
||||
|
||||
@@ -101,6 +101,10 @@ export type CampaignDeltaResponse = {
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
|
||||
@@ -185,6 +189,58 @@ export type CampaignRecipientAddressSourcesResponse = {
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
};
|
||||
|
||||
export type CampaignPostboxDirectoryEntry = {
|
||||
id: string;
|
||||
address: string;
|
||||
address_key: string;
|
||||
name: string;
|
||||
status: string;
|
||||
classification: string;
|
||||
organization_unit_id?: string | null;
|
||||
organization_unit_name?: string | null;
|
||||
function_id?: string | null;
|
||||
function_name?: string | null;
|
||||
context_key?: string | null;
|
||||
holder_count: number;
|
||||
vacant: boolean;
|
||||
};
|
||||
|
||||
export type CampaignPostboxTemplate = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
published_revision_id: string;
|
||||
function_type_id?: string | null;
|
||||
scope_kind: string;
|
||||
scope_id?: string | null;
|
||||
classification: string;
|
||||
allow_vacant_delivery: boolean;
|
||||
};
|
||||
|
||||
export type CampaignPostboxFunction = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
function_type_id?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignPostboxOrganizationUnit = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
unit_type_id?: string | null;
|
||||
parent_id?: string | null;
|
||||
functions: CampaignPostboxFunction[];
|
||||
};
|
||||
|
||||
export type CampaignPostboxCatalog = {
|
||||
available: boolean;
|
||||
postboxes: CampaignPostboxDirectoryEntry[];
|
||||
templates: CampaignPostboxTemplate[];
|
||||
organization_units: CampaignPostboxOrganizationUnit[];
|
||||
};
|
||||
|
||||
export type CampaignRecipientSnapshotItem = {
|
||||
contact_id: string;
|
||||
display_name: string;
|
||||
@@ -285,6 +341,7 @@ export type CampaignDeliveryOptions = {
|
||||
campaign_id: string;
|
||||
version_id: string;
|
||||
worker_queue_available: boolean;
|
||||
postbox_available: boolean;
|
||||
synchronous_send: {
|
||||
allowed?: boolean;
|
||||
reason?: string | null;
|
||||
@@ -448,6 +505,7 @@ export type CampaignJobDetailResponse = {
|
||||
attempts: {
|
||||
smtp?: Record<string, unknown>[];
|
||||
imap?: Record<string, unknown>[];
|
||||
postbox?: Record<string, unknown>[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -488,6 +546,9 @@ export type AggregateCampaignReport = {
|
||||
};
|
||||
outcomes: {
|
||||
smtp_accepted: AggregateReportCount;
|
||||
postbox_accepted: AggregateReportCount;
|
||||
delivered: AggregateReportCount;
|
||||
partially_accepted: AggregateReportCount;
|
||||
failed: AggregateReportCount;
|
||||
outcome_unknown: AggregateReportCount;
|
||||
queued_or_active: AggregateReportCount;
|
||||
@@ -582,6 +643,13 @@ campaignId: string)
|
||||
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
|
||||
}
|
||||
|
||||
export async function getCampaignPostboxCatalog(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
: Promise<CampaignPostboxCatalog> {
|
||||
return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
|
||||
}
|
||||
|
||||
export async function snapshotCampaignRecipientAddressSource(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
@@ -961,12 +1029,13 @@ export async function resolveCampaignJobOutcome(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string,
|
||||
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended",
|
||||
note?: string)
|
||||
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended" | "postbox_accepted" | "postbox_not_accepted",
|
||||
note?: string,
|
||||
attemptId?: string)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision, note: note || null })
|
||||
body: JSON.stringify({ decision, note: note || null, attempt_id: attemptId || null })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1057,8 +1126,15 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
||||
}
|
||||
|
||||
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
||||
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||
return response.shares;
|
||||
const pageSize = 500;
|
||||
const shares: CampaignShare[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await apiFetch<{shares: CampaignShare[];pages?: number;}>(settings, `/api/v1/campaigns/${campaignId}/shares?page=${page}&page_size=${pageSize}`);
|
||||
shares.push(...response.shares);
|
||||
if (page >= (response.pages ?? 1)) {
|
||||
return shares;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCampaignOwner(
|
||||
|
||||
125
webui/src/features/campaigns/CampaignActivityWidget.tsx
Normal file
125
webui/src/features/campaigns/CampaignActivityWidget.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
StatusBadge,
|
||||
useDashboardWidgetData,
|
||||
type ApiSettings,
|
||||
type CampaignListItem,
|
||||
type DashboardWidgetConfiguration
|
||||
} from "@govoplan/core-webui";
|
||||
import { listCampaigns } from "../../api/campaigns";
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
"sent",
|
||||
"cancelled",
|
||||
"archived",
|
||||
"deleted"
|
||||
]);
|
||||
|
||||
export default function CampaignActivityWidget({
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
refreshKey: number;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
}) {
|
||||
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
|
||||
const showCompleted = configuration.showCompleted === true;
|
||||
const load = useCallback(async () => {
|
||||
const campaigns = await listCampaigns(settings);
|
||||
return campaigns
|
||||
.filter(
|
||||
(campaign) =>
|
||||
showCompleted || !TERMINAL_STATUSES.has(campaign.status)
|
||||
)
|
||||
.sort(compareCampaigns)
|
||||
.slice(0, maxItems);
|
||||
}, [maxItems, settings, showCompleted]);
|
||||
const { data: campaigns, loading, error } = useDashboardWidgetData(
|
||||
load,
|
||||
refreshKey
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="Loading campaign activity">
|
||||
{error && (
|
||||
<DismissibleAlert tone="warning" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<DashboardWidgetList
|
||||
emptyText="No active campaigns."
|
||||
items={(campaigns ?? []).map((campaign) => ({
|
||||
id: campaign.id,
|
||||
title: campaign.name,
|
||||
detail: campaignProgress(campaign),
|
||||
meta: updatedLabel(campaign),
|
||||
leading: <Send size={17} aria-hidden="true" />,
|
||||
trailing: (
|
||||
<StatusBadge status={campaign.status} label={statusLabel(campaign.status)} />
|
||||
),
|
||||
to: `/campaigns/${encodeURIComponent(campaign.id)}`
|
||||
}))}
|
||||
/>
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link className="btn btn-secondary" to="/campaigns">
|
||||
Open campaigns
|
||||
</Link>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function compareCampaigns(
|
||||
left: CampaignListItem,
|
||||
right: CampaignListItem
|
||||
): number {
|
||||
return timestamp(right) - timestamp(left);
|
||||
}
|
||||
|
||||
function timestamp(campaign: CampaignListItem): number {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
|
||||
return value ? new Date(value).getTime() : 0;
|
||||
}
|
||||
|
||||
function updatedLabel(campaign: CampaignListItem): string {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
|
||||
return value
|
||||
? new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short"
|
||||
}).format(new Date(value))
|
||||
: "";
|
||||
}
|
||||
|
||||
function campaignProgress(campaign: CampaignListItem): string {
|
||||
const parts = [
|
||||
campaign.sent ? `${campaign.sent} sent` : "",
|
||||
campaign.failed ? `${campaign.failed} failed` : "",
|
||||
campaign.blocked ? `${campaign.blocked} blocked` : "",
|
||||
campaign.warnings ? `${campaign.warnings} warnings` : ""
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ") || campaign.description || "No delivery totals yet";
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function numberSetting(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number
|
||||
): number {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric)
|
||||
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
|
||||
: fallback;
|
||||
}
|
||||
@@ -32,7 +32,11 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
||||
let response: CampaignDeltaResponse;
|
||||
do {
|
||||
response = await listCampaignsDelta(settings, { since: nextWatermark });
|
||||
nextCampaigns = mergeCampaignDelta(nextCampaigns, response);
|
||||
nextCampaigns = mergeCampaignDelta(
|
||||
nextCampaigns,
|
||||
response,
|
||||
Boolean(response.full && nextWatermark?.startsWith("full:campaigns:"))
|
||||
);
|
||||
nextWatermark = response.watermark ?? null;
|
||||
} while (response.has_more);
|
||||
setCampaigns(nextCampaigns);
|
||||
@@ -199,8 +203,12 @@ function formatLoadedAt(value: Date): string {
|
||||
return formatDateTimeFromDate(value, { second: "2-digit" });
|
||||
}
|
||||
|
||||
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] {
|
||||
if (response.full) return response.campaigns;
|
||||
function mergeCampaignDelta(
|
||||
current: CampaignListItem[],
|
||||
response: CampaignDeltaResponse,
|
||||
continuingFullSnapshot = false
|
||||
): CampaignListItem[] {
|
||||
if (response.full && !continuingFullSnapshot) return response.campaigns;
|
||||
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
|
||||
deletedResourceType: "campaign",
|
||||
sort: sortCampaignsByUpdatedDesc
|
||||
|
||||
@@ -43,6 +43,9 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"claimed",
|
||||
"sending",
|
||||
"smtp_accepted",
|
||||
"postbox_accepted",
|
||||
"delivered",
|
||||
"partially_accepted",
|
||||
"sent",
|
||||
"outcome_unknown",
|
||||
"failed_temporary",
|
||||
@@ -50,6 +53,19 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"cancelled"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"pending",
|
||||
"delivering",
|
||||
"accepted",
|
||||
"accepted_vacant",
|
||||
"partially_accepted",
|
||||
"rejected_temporary",
|
||||
"rejected_permanent",
|
||||
"outcome_unknown",
|
||||
"skipped"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"pending",
|
||||
@@ -253,7 +269,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
});
|
||||
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
|
||||
const status = String(sendResult.status ?? "submitted");
|
||||
if (status === "smtp_accepted" || status === "already_accepted") accepted += 1;
|
||||
if (["smtp_accepted", "postbox_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
|
||||
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
|
||||
} catch (err) {
|
||||
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -353,9 +369,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
|
||||
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "send", header: "Delivery", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, value: (row) => String(row.postbox_status ?? "unknown") },
|
||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, value: (row) => String(row.imap_status ?? "unknown") },
|
||||
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
|
||||
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) },
|
||||
{
|
||||
id: "evidence",
|
||||
header: "i18n:govoplan-campaign.evidence.7ea014de",
|
||||
@@ -540,10 +557,13 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
||||
</dl>
|
||||
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
|
||||
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
|
||||
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
|
||||
</div>
|
||||
}
|
||||
@@ -565,13 +585,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
|
||||
}
|
||||
|
||||
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
|
||||
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
|
||||
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record<string, unknown>[];}) {
|
||||
const title = kind === "smtp"
|
||||
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
|
||||
: kind === "postbox"
|
||||
? "Postbox delivery attempts"
|
||||
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="attempt-history-section">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
|
||||
<p className="muted small-note">No {kind} attempt has been recorded for this job.</p>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -581,10 +605,12 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
||||
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
|
||||
kind === "imap" ?
|
||||
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
|
||||
kind === "postbox" ?
|
||||
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
|
||||
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
|
||||
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
|
||||
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
|
||||
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</span> }
|
||||
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? row.error_code ?? "")}>{String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—")}</span> }
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -601,9 +627,11 @@ function initialReportGridFilters(): Record<string, string | string[]> {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
|
||||
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
|
||||
const postbox = statusParameters(params, "postbox_status", POSTBOX_STATUS_OPTIONS);
|
||||
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
|
||||
if (send.length > 0) result.send = send;
|
||||
if (imap.length > 0) result.imap = imap;
|
||||
if (postbox.length > 0) result.postbox = postbox;
|
||||
if (validation.length > 0) result.validation = validation;
|
||||
return result;
|
||||
}
|
||||
@@ -635,14 +663,14 @@ function serializeInitialGridFilters(filters: Record<string, string | string[]>)
|
||||
}
|
||||
|
||||
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
|
||||
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") {
|
||||
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "imap" || value === "attempts" || value === "updated") {
|
||||
return value;
|
||||
}
|
||||
return "number";
|
||||
}
|
||||
|
||||
function retryableFailedStatus(status: string): boolean {
|
||||
return status === "failed_temporary" || status === "failed_permanent";
|
||||
return status === "failed_temporary" || status === "failed_permanent" || status === "partially_accepted";
|
||||
}
|
||||
|
||||
function shortJobId(jobId: string): string {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
getCampaignPostboxCatalog,
|
||||
type CampaignPostboxCatalog
|
||||
} from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
@@ -14,10 +18,15 @@ import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { hasScope } from "@govoplan/core-webui";
|
||||
import { RetentionPolicyEditor } from "@govoplan/core-webui";
|
||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import PostboxTargetsDialog, {
|
||||
normalizePostboxTargets
|
||||
} from "./components/PostboxTargetsDialog";
|
||||
|
||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||
|
||||
@@ -34,6 +43,14 @@ type GlobalSettingsPageProps = {
|
||||
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [editorState, setEditorState] = useState<EditorState>({});
|
||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
const [postboxTargetsOpen, setPostboxTargetsOpen] = useState(false);
|
||||
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
|
||||
const isPolicyView = view === "policy";
|
||||
|
||||
const version = data.currentVersion;
|
||||
@@ -59,12 +76,53 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
const delivery = asRecord(displayDraft.delivery);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const retry = asRecord(delivery.retry);
|
||||
const postboxDelivery = asRecord(delivery.postbox);
|
||||
const fieldDefinitions = useMemo(
|
||||
() => getDraftFields(displayDraft),
|
||||
[displayDraft]
|
||||
);
|
||||
const statusTracking = asRecord(displayDraft.status_tracking);
|
||||
const optIns = asRecord(editorState.opt_ins);
|
||||
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
||||
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
|
||||
const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
|
||||
|
||||
useEffect(() => {
|
||||
if (!postboxModuleInstalled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getCampaignPostboxCatalog(settings, campaignId)
|
||||
.then((catalog) => {
|
||||
if (!cancelled) setPostboxCatalog(catalog);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
campaignId,
|
||||
postboxModuleInstalled,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
function patchEditor(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setEditorState((current) => updateNested(current, path, value));
|
||||
@@ -232,6 +290,43 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||
</div>
|
||||
{postboxModuleInstalled &&
|
||||
<div className="campaign-postbox-delivery-settings">
|
||||
<FormField label="Delivery channels">
|
||||
<select
|
||||
value={getText(delivery, "channel_policy", "mail")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "channel_policy"], event.target.value)}
|
||||
>
|
||||
<option value="mail">Mail</option>
|
||||
<option value="postbox">Postbox</option>
|
||||
<option value="mail_and_postbox">Mail and Postbox</option>
|
||||
<option value="mail_then_postbox">Mail, then Postbox on pre-acceptance rejection</option>
|
||||
<option value="postbox_then_mail">Postbox, then Mail on pre-acceptance rejection</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Postbox classification">
|
||||
<input
|
||||
value={getText(postboxDelivery, "classification", "internal")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "postbox", "classification"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Default Postbox targets">
|
||||
<Button
|
||||
disabled={locked || !postboxCatalog.available}
|
||||
onClick={() => setPostboxTargetsOpen(true)}
|
||||
>
|
||||
Configure ({normalizePostboxTargets(postboxDelivery.targets).length})
|
||||
</Button>
|
||||
</FormField>
|
||||
{!postboxCatalog.available &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
Postbox delivery is installed but its delivery catalog is unavailable.
|
||||
</DismissibleAlert>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
|
||||
@@ -246,6 +341,21 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
</>
|
||||
}
|
||||
</LoadingFrame>
|
||||
{postboxTargetsOpen &&
|
||||
<PostboxTargetsDialog
|
||||
open
|
||||
title="Default Postbox targets"
|
||||
catalog={postboxCatalog}
|
||||
fields={fieldDefinitions}
|
||||
targets={normalizePostboxTargets(postboxDelivery.targets)}
|
||||
locked={locked}
|
||||
onSave={(targets) => {
|
||||
patch(["delivery", "postbox", "targets"], targets);
|
||||
setPostboxTargetsOpen(false);
|
||||
}}
|
||||
onClose={() => setPostboxTargetsOpen(false)}
|
||||
/>
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createRecipientImportMappingProfile,
|
||||
getCampaignPostboxCatalog,
|
||||
listCampaignRecipientAddressSources,
|
||||
listRecipientImportMappingProfiles,
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
updateRecipientImportMappingProfile,
|
||||
type CampaignPostboxCatalog,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot,
|
||||
type RecipientImportMappingProfilePayload } from
|
||||
@@ -30,6 +32,9 @@ import { getBool } from "./utils/draftEditor";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||
import PostboxTargetsDialog, {
|
||||
normalizePostboxTargets
|
||||
} from "./components/PostboxTargetsDialog";
|
||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import {
|
||||
@@ -105,6 +110,7 @@ const recipientAddressOverlayColumns: EntryAddressColumn[] = [
|
||||
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
|
||||
@@ -115,6 +121,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
||||
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
||||
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
||||
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
|
||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
@@ -192,12 +205,53 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postboxModuleInstalled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getCampaignPostboxCatalog(settings, campaignId)
|
||||
.then((catalog) => {
|
||||
if (!cancelled) setPostboxCatalog(catalog);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
campaignId,
|
||||
postboxModuleInstalled,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setRecipientAddressEditorIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (inlineEntries.length === 0) return null;
|
||||
return Math.max(0, Math.min(current, inlineEntries.length - 1));
|
||||
});
|
||||
setPostboxTargetEditorIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (inlineEntries.length === 0) return null;
|
||||
return Math.max(0, Math.min(current, inlineEntries.length - 1));
|
||||
});
|
||||
}, [inlineEntries.length]);
|
||||
|
||||
|
||||
@@ -251,6 +305,19 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
setRecipientAddressEditorIndex(null);
|
||||
}
|
||||
|
||||
function saveEntryPostboxTargets(
|
||||
index: number,
|
||||
targets: ReturnType<typeof normalizePostboxTargets>,
|
||||
merge: boolean
|
||||
) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
postbox_targets: targets,
|
||||
merge_postbox_targets: merge
|
||||
}));
|
||||
setPostboxTargetEditorIndex(null);
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
@@ -449,12 +516,15 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
draft: displayDraft,
|
||||
locked,
|
||||
filesModuleInstalled,
|
||||
postboxModuleInstalled,
|
||||
postboxCatalog,
|
||||
entries: inlineEntries,
|
||||
fieldDefinitions,
|
||||
individualAttachmentBasePaths,
|
||||
zipConfig,
|
||||
translateText,
|
||||
openAddressEditor: setRecipientAddressEditorIndex,
|
||||
openPostboxTargetEditor: setPostboxTargetEditorIndex,
|
||||
updateEntry,
|
||||
updateEntryAttachments,
|
||||
updateEntryField,
|
||||
@@ -512,6 +582,20 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
|
||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
||||
|
||||
}
|
||||
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
|
||||
<PostboxTargetsDialog
|
||||
open
|
||||
title={`Recipient ${postboxTargetEditorIndex + 1} - Postbox targets`}
|
||||
catalog={postboxCatalog}
|
||||
fields={fieldDefinitions}
|
||||
targets={normalizePostboxTargets(inlineEntries[postboxTargetEditorIndex].postbox_targets)}
|
||||
merge={inlineEntries[postboxTargetEditorIndex].merge_postbox_targets !== false}
|
||||
showMerge
|
||||
locked={locked}
|
||||
onSave={(targets, merge) => saveEntryPostboxTargets(postboxTargetEditorIndex, targets, merge)}
|
||||
onClose={() => setPostboxTargetEditorIndex(null)} />
|
||||
|
||||
}
|
||||
{headerAddressEditor &&
|
||||
<HeaderAddressEditorDialog
|
||||
@@ -2012,12 +2096,15 @@ type RecipientProfileColumnContext = {
|
||||
draft: Record<string, unknown>;
|
||||
locked: boolean;
|
||||
filesModuleInstalled: boolean;
|
||||
postboxModuleInstalled: boolean;
|
||||
postboxCatalog: CampaignPostboxCatalog;
|
||||
entries: Record<string, unknown>[];
|
||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||
zipConfig: AttachmentZipCollection;
|
||||
translateText: (value: string) => string;
|
||||
openAddressEditor: (index: number) => void;
|
||||
openPostboxTargetEditor: (index: number) => void;
|
||||
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
||||
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
|
||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||
@@ -2026,7 +2113,7 @@ type RecipientProfileColumnContext = {
|
||||
removeEntry: (index: number) => void;
|
||||
};
|
||||
|
||||
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{
|
||||
id: "number",
|
||||
@@ -2075,6 +2162,45 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod
|
||||
value: recipientAddressFilterValue
|
||||
},
|
||||
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
|
||||
...(postboxModuleInstalled ? [{
|
||||
id: "delivery",
|
||||
header: "Delivery",
|
||||
width: "minmax(260px, 0.9fr)",
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
render: (entry, index) => {
|
||||
const targets = normalizePostboxTargets(entry.postbox_targets);
|
||||
return (
|
||||
<div className="campaign-recipient-delivery-cell">
|
||||
<select
|
||||
value={typeof entry.channel_policy === "string" ? entry.channel_policy : ""}
|
||||
disabled={locked}
|
||||
aria-label={`Recipient ${index + 1} delivery policy`}
|
||||
onChange={(event) => updateEntry(index, (current) => {
|
||||
const next = { ...current };
|
||||
if (event.target.value) next.channel_policy = event.target.value;
|
||||
else delete next.channel_policy;
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
<option value="">Campaign default</option>
|
||||
<option value="mail">Mail</option>
|
||||
<option value="postbox">Postbox</option>
|
||||
<option value="mail_and_postbox">Mail and Postbox</option>
|
||||
<option value="mail_then_postbox">Mail, then Postbox fallback</option>
|
||||
<option value="postbox_then_mail">Postbox, then Mail fallback</option>
|
||||
</select>
|
||||
<Button
|
||||
disabled={locked || !postboxCatalog.available}
|
||||
onClick={() => openPostboxTargetEditor(index)}
|
||||
>
|
||||
Postboxes ({targets.length})
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")}`
|
||||
} as DataGridColumn<Record<string, unknown>>] : []),
|
||||
...(individualAttachmentBasePaths.length > 0 ? [{
|
||||
id: "attachments",
|
||||
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
||||
|
||||
567
webui/src/features/campaigns/components/PostboxTargetsDialog.tsx
Normal file
567
webui/src/features/campaigns/components/PostboxTargetsDialog.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
CampaignPostboxCatalog,
|
||||
CampaignPostboxOrganizationUnit
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignFieldDefinition } from "../utils/fieldDefinitions";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
|
||||
export type CampaignPostboxTarget = {
|
||||
id: string;
|
||||
mode: "direct" | "derived";
|
||||
label?: string;
|
||||
postbox_id?: string;
|
||||
address_key?: string;
|
||||
template_id?: string;
|
||||
organization_unit_id?: string;
|
||||
organization_unit_field?: string;
|
||||
organization_unit_match?: "id" | "slug";
|
||||
function_id?: string;
|
||||
function_field?: string;
|
||||
function_match?: "id" | "slug";
|
||||
context_key?: string;
|
||||
context_field?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
targets: CampaignPostboxTarget[];
|
||||
locked?: boolean;
|
||||
merge?: boolean;
|
||||
showMerge?: boolean;
|
||||
onSave: (targets: CampaignPostboxTarget[], merge: boolean) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function PostboxTargetsDialog({
|
||||
open,
|
||||
title,
|
||||
catalog,
|
||||
fields,
|
||||
targets,
|
||||
locked = false,
|
||||
merge = true,
|
||||
showMerge = false,
|
||||
onSave,
|
||||
onClose
|
||||
}: Props) {
|
||||
const [draftTargets, setDraftTargets] = useState<CampaignPostboxTarget[]>(
|
||||
() => targets.map(normalizeTarget)
|
||||
);
|
||||
const [draftMerge, setDraftMerge] = useState(merge);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function updateTarget(index: number, patch: Partial<CampaignPostboxTarget>) {
|
||||
setDraftTargets((current) =>
|
||||
current.map((target, currentIndex) =>
|
||||
currentIndex === index ? normalizeTarget({ ...target, ...patch }) : target
|
||||
)
|
||||
);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function addTarget() {
|
||||
const firstPostbox = catalog.postboxes[0];
|
||||
setDraftTargets((current) => [
|
||||
...current,
|
||||
{
|
||||
id: newTargetId(),
|
||||
mode: "direct",
|
||||
postbox_id: firstPostbox?.id ?? ""
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const prepared = draftTargets.map(normalizeTarget);
|
||||
const invalidIndex = prepared.findIndex((target) => !targetIsComplete(target));
|
||||
if (invalidIndex >= 0) {
|
||||
setError(`Target ${invalidIndex + 1} is incomplete.`);
|
||||
return;
|
||||
}
|
||||
onSave(prepared, draftMerge);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={title}
|
||||
className="campaign-postbox-target-modal"
|
||||
bodyClassName="campaign-postbox-target-body"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={locked} onClick={save}>Save</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="campaign-postbox-target-toolbar">
|
||||
{showMerge &&
|
||||
<ToggleSwitch
|
||||
label="Postbox target defaults"
|
||||
inactiveLabel="Replace defaults"
|
||||
activeLabel="Add to defaults"
|
||||
checked={draftMerge}
|
||||
disabled={locked}
|
||||
onChange={setDraftMerge}
|
||||
/>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={locked || catalog.postboxes.length + catalog.templates.length === 0}
|
||||
onClick={addTarget}
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
Add target
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
|
||||
{draftTargets.length === 0 &&
|
||||
<div className="data-grid-empty">No Postbox targets configured.</div>
|
||||
}
|
||||
<div className="campaign-postbox-target-list">
|
||||
{draftTargets.map((target, index) =>
|
||||
<PostboxTargetRow
|
||||
key={target.id}
|
||||
target={target}
|
||||
index={index}
|
||||
count={draftTargets.length}
|
||||
catalog={catalog}
|
||||
fields={fields}
|
||||
locked={locked}
|
||||
onChange={(patch) => updateTarget(index, patch)}
|
||||
onMove={(offset) => setDraftTargets((current) => moveTarget(current, index, index + offset))}
|
||||
onRemove={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PostboxTargetRow({
|
||||
target,
|
||||
index,
|
||||
count,
|
||||
catalog,
|
||||
fields,
|
||||
locked,
|
||||
onChange,
|
||||
onMove,
|
||||
onRemove
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
index: number;
|
||||
count: number;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
onMove: (offset: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const selectedUnit = catalog.organization_units.find(
|
||||
(unit) => unit.id === target.organization_unit_id
|
||||
);
|
||||
return (
|
||||
<section className="campaign-postbox-target-row">
|
||||
<div className="campaign-postbox-target-row-heading">
|
||||
<strong>Target {index + 1}</strong>
|
||||
<TableActionGroup actions={[
|
||||
{ id: "up", label: "Move target up", icon: <ArrowUp aria-hidden="true" />, disabled: locked || index === 0, onClick: () => onMove(-1) },
|
||||
{ id: "down", label: "Move target down", icon: <ArrowDown aria-hidden="true" />, disabled: locked || index === count - 1, onClick: () => onMove(1) },
|
||||
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
|
||||
]} />
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
|
||||
<FormField label="Target type">
|
||||
<select
|
||||
value={target.mode}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange(
|
||||
event.target.value === "derived"
|
||||
? derivedTargetDefaults(target.id, catalog, fields)
|
||||
: directTargetDefaults(target.id, catalog)
|
||||
)}
|
||||
>
|
||||
<option value="direct">Direct Postbox</option>
|
||||
<option value="derived">Derived from fields</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Label">
|
||||
<input
|
||||
value={target.label ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ label: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
{target.mode === "direct" ?
|
||||
<DirectTargetFields
|
||||
target={target}
|
||||
catalog={catalog}
|
||||
locked={locked}
|
||||
onChange={onChange}
|
||||
/> :
|
||||
<DerivedTargetFields
|
||||
target={target}
|
||||
catalog={catalog}
|
||||
fields={fields}
|
||||
selectedUnit={selectedUnit}
|
||||
locked={locked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectTargetFields({
|
||||
target,
|
||||
catalog,
|
||||
locked,
|
||||
onChange
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormField label="Postbox">
|
||||
<select
|
||||
value={target.postbox_id ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ postbox_id: event.target.value })}
|
||||
>
|
||||
<option value="">Select a Postbox</option>
|
||||
{catalog.postboxes.map((postbox) =>
|
||||
<option key={postbox.id} value={postbox.id}>
|
||||
{postbox.name} ({postbox.address}){postbox.vacant ? " - vacant" : ""}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function DerivedTargetFields({
|
||||
target,
|
||||
catalog,
|
||||
fields,
|
||||
selectedUnit,
|
||||
locked,
|
||||
onChange
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
selectedUnit?: CampaignPostboxOrganizationUnit;
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
}) {
|
||||
const organizationFields = useMemo(
|
||||
() => fields.filter((field) => field.type === "organization_unit" || field.type === "string"),
|
||||
[fields]
|
||||
);
|
||||
const functionFields = useMemo(
|
||||
() => fields.filter((field) => field.type === "organization_function" || field.type === "string"),
|
||||
[fields]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FormField label="Postbox template">
|
||||
<select
|
||||
value={target.template_id ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ template_id: event.target.value })}
|
||||
>
|
||||
<option value="">Select a template</option>
|
||||
{catalog.templates.map((template) =>
|
||||
<option key={template.id} value={template.id}>{template.name}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<DerivedReferenceField
|
||||
label="Organization unit"
|
||||
fixedValue={target.organization_unit_id}
|
||||
fieldValue={target.organization_unit_field}
|
||||
match={target.organization_unit_match}
|
||||
fixedOptions={catalog.organization_units}
|
||||
fields={organizationFields}
|
||||
locked={locked}
|
||||
onFixed={(value) => onChange({
|
||||
organization_unit_id: value,
|
||||
organization_unit_field: undefined,
|
||||
function_id: undefined
|
||||
})}
|
||||
onField={(value) => onChange({
|
||||
organization_unit_id: undefined,
|
||||
organization_unit_field: value
|
||||
})}
|
||||
onMatch={(value) => onChange({ organization_unit_match: value })}
|
||||
/>
|
||||
<DerivedReferenceField
|
||||
label="Function"
|
||||
fixedValue={target.function_id}
|
||||
fieldValue={target.function_field}
|
||||
match={target.function_match}
|
||||
fixedOptions={selectedUnit?.functions ?? []}
|
||||
fields={functionFields}
|
||||
locked={locked}
|
||||
fixedDisabled={!target.organization_unit_id}
|
||||
onFixed={(value) => onChange({
|
||||
function_id: value,
|
||||
function_field: undefined
|
||||
})}
|
||||
onField={(value) => onChange({
|
||||
function_id: undefined,
|
||||
function_field: value
|
||||
})}
|
||||
onMatch={(value) => onChange({ function_match: value })}
|
||||
/>
|
||||
<FormField label="Context source">
|
||||
<select
|
||||
value={target.context_field ? "field" : target.context_key ? "fixed" : "none"}
|
||||
disabled={locked}
|
||||
onChange={(event) => {
|
||||
const mode = event.target.value;
|
||||
onChange({
|
||||
context_key: mode === "fixed" ? target.context_key ?? "" : undefined,
|
||||
context_field: mode === "field" ? target.context_field ?? fields[0]?.name ?? "" : undefined
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="none">No context</option>
|
||||
<option value="fixed">Fixed value</option>
|
||||
<option value="field">Campaign field</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{target.context_field !== undefined ?
|
||||
<FormField label="Context field">
|
||||
<select
|
||||
value={target.context_field}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ context_field: event.target.value })}
|
||||
>
|
||||
<option value="">Select a field</option>
|
||||
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||
</select>
|
||||
</FormField> :
|
||||
target.context_key !== undefined &&
|
||||
<FormField label="Context value">
|
||||
<input
|
||||
value={target.context_key}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ context_key: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DerivedReferenceField({
|
||||
label,
|
||||
fixedValue,
|
||||
fieldValue,
|
||||
match,
|
||||
fixedOptions,
|
||||
fields,
|
||||
locked,
|
||||
fixedDisabled = false,
|
||||
onFixed,
|
||||
onField,
|
||||
onMatch
|
||||
}: {
|
||||
label: string;
|
||||
fixedValue?: string;
|
||||
fieldValue?: string;
|
||||
match?: "id" | "slug";
|
||||
fixedOptions: Array<{ id: string; name: string; slug: string }>;
|
||||
fields: CampaignFieldDefinition[];
|
||||
locked: boolean;
|
||||
fixedDisabled?: boolean;
|
||||
onFixed: (value: string) => void;
|
||||
onField: (value: string) => void;
|
||||
onMatch: (value: "id" | "slug") => void;
|
||||
}) {
|
||||
const usesField = fieldValue !== undefined;
|
||||
return (
|
||||
<>
|
||||
<FormField label={`${label} source`}>
|
||||
<select
|
||||
value={usesField ? "field" : "fixed"}
|
||||
disabled={locked}
|
||||
onChange={(event) =>
|
||||
event.target.value === "field"
|
||||
? onField(fieldValue ?? fields[0]?.name ?? "")
|
||||
: onFixed(fixedValue ?? fixedOptions[0]?.id ?? "")
|
||||
}
|
||||
>
|
||||
<option value="fixed">Fixed value</option>
|
||||
<option value="field">Campaign field</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{usesField ?
|
||||
<>
|
||||
<FormField label={`${label} field`}>
|
||||
<select value={fieldValue ?? ""} disabled={locked} onChange={(event) => onField(event.target.value)}>
|
||||
<option value="">Select a field</option>
|
||||
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Field contains">
|
||||
<select value={match ?? "id"} disabled={locked} onChange={(event) => onMatch(event.target.value as "id" | "slug")}>
|
||||
<option value="id">Stable ID</option>
|
||||
<option value="slug">Key / slug</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</> :
|
||||
<FormField label={label}>
|
||||
<select value={fixedValue ?? ""} disabled={locked || fixedDisabled} onChange={(event) => onFixed(event.target.value)}>
|
||||
<option value="">Select {label.toLowerCase()}</option>
|
||||
{fixedOptions.map((option) => <option key={option.id} value={option.id}>{option.name} ({option.slug})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePostboxTargets(value: unknown): CampaignPostboxTarget[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(asRecord)
|
||||
.map((target) => normalizeTarget({
|
||||
id: String(target.id ?? ""),
|
||||
mode: target.mode === "derived" ? "derived" : "direct",
|
||||
label: optionalText(target.label),
|
||||
postbox_id: optionalText(target.postbox_id),
|
||||
address_key: optionalText(target.address_key),
|
||||
template_id: optionalText(target.template_id),
|
||||
organization_unit_id: optionalText(target.organization_unit_id),
|
||||
organization_unit_field: optionalText(target.organization_unit_field),
|
||||
organization_unit_match: target.organization_unit_match === "slug" ? "slug" : "id",
|
||||
function_id: optionalText(target.function_id),
|
||||
function_field: optionalText(target.function_field),
|
||||
function_match: target.function_match === "slug" ? "slug" : "id",
|
||||
context_key: optionalText(target.context_key),
|
||||
context_field: optionalText(target.context_field)
|
||||
}))
|
||||
.filter((target) => Boolean(target.id));
|
||||
}
|
||||
|
||||
function normalizeTarget(target: CampaignPostboxTarget): CampaignPostboxTarget {
|
||||
const common = {
|
||||
id: target.id || newTargetId(),
|
||||
mode: target.mode,
|
||||
...(target.label?.trim() ? { label: target.label.trim() } : {})
|
||||
};
|
||||
if (target.mode === "direct") {
|
||||
return {
|
||||
...common,
|
||||
mode: "direct",
|
||||
...(target.postbox_id ? { postbox_id: target.postbox_id } : {}),
|
||||
...(!target.postbox_id && target.address_key ? { address_key: target.address_key } : {})
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
mode: "derived",
|
||||
template_id: target.template_id ?? "",
|
||||
...(target.organization_unit_field !== undefined
|
||||
? {
|
||||
organization_unit_field: target.organization_unit_field,
|
||||
organization_unit_match: target.organization_unit_match ?? "id"
|
||||
}
|
||||
: { organization_unit_id: target.organization_unit_id ?? "" }),
|
||||
...(target.function_field !== undefined
|
||||
? {
|
||||
function_field: target.function_field,
|
||||
function_match: target.function_match ?? "id"
|
||||
}
|
||||
: { function_id: target.function_id ?? "" }),
|
||||
...(target.context_field !== undefined
|
||||
? { context_field: target.context_field }
|
||||
: target.context_key !== undefined
|
||||
? { context_key: target.context_key }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function targetIsComplete(target: CampaignPostboxTarget): boolean {
|
||||
if (!target.id) return false;
|
||||
if (target.mode === "direct") {
|
||||
return Boolean(target.postbox_id || target.address_key);
|
||||
}
|
||||
return Boolean(
|
||||
target.template_id
|
||||
&& (target.organization_unit_id || target.organization_unit_field)
|
||||
&& (target.function_id || target.function_field)
|
||||
);
|
||||
}
|
||||
|
||||
function directTargetDefaults(id: string, catalog: CampaignPostboxCatalog): CampaignPostboxTarget {
|
||||
return {
|
||||
id,
|
||||
mode: "direct",
|
||||
postbox_id: catalog.postboxes[0]?.id ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function derivedTargetDefaults(
|
||||
id: string,
|
||||
catalog: CampaignPostboxCatalog,
|
||||
fields: CampaignFieldDefinition[]
|
||||
): CampaignPostboxTarget {
|
||||
const unit = catalog.organization_units[0];
|
||||
return {
|
||||
id,
|
||||
mode: "derived",
|
||||
template_id: catalog.templates[0]?.id ?? "",
|
||||
...(unit
|
||||
? {
|
||||
organization_unit_id: unit.id,
|
||||
function_id: unit.functions[0]?.id ?? ""
|
||||
}
|
||||
: {
|
||||
organization_unit_field: fields[0]?.name ?? "",
|
||||
organization_unit_match: "id",
|
||||
function_field: fields[0]?.name ?? "",
|
||||
function_match: "id"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function moveTarget(
|
||||
targets: CampaignPostboxTarget[],
|
||||
source: number,
|
||||
destination: number
|
||||
): CampaignPostboxTarget[] {
|
||||
if (destination < 0 || destination >= targets.length) return targets;
|
||||
const next = [...targets];
|
||||
const [target] = next.splice(source, 1);
|
||||
next.splice(destination, 0, target);
|
||||
return next;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown): string | undefined {
|
||||
const text = String(value ?? "").trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function newTargetId(): string {
|
||||
const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `postbox-${suffix}`;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { asArray, asRecord } from "./campaignView";
|
||||
import { getBool, getText } from "./draftEditor";
|
||||
|
||||
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
|
||||
export const fieldTypeOptions = [
|
||||
"string",
|
||||
"integer",
|
||||
"double",
|
||||
"date",
|
||||
"password",
|
||||
"organization_unit",
|
||||
"organization_function"
|
||||
] as const;
|
||||
export type CampaignFieldType = typeof fieldTypeOptions[number];
|
||||
|
||||
export type CampaignFieldDefinition = {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type CampaignJobSortColumn =
|
||||
| "validation"
|
||||
| "queue"
|
||||
| "send"
|
||||
| "postbox"
|
||||
| "imap"
|
||||
| "attempts"
|
||||
| "updated";
|
||||
@@ -31,6 +32,7 @@ const FILTER_PARAMETERS: Record<string, string> = {
|
||||
validation: "filter_validation",
|
||||
queue: "filter_queue",
|
||||
send: "filter_send",
|
||||
postbox: "filter_postbox",
|
||||
imap: "filter_imap",
|
||||
attempts: "filter_attempts",
|
||||
evidence: "filter_evidence"
|
||||
|
||||
@@ -214,6 +214,9 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
||||
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" />
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createElement, lazy, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import {
|
||||
ResourceAccessBoundary,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DashboardWidgetsUiCapability,
|
||||
type PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import { getCampaign } from "./api/campaigns";
|
||||
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/campaign-workspace.css";
|
||||
|
||||
@@ -17,6 +24,50 @@ const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
const campaignDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
{
|
||||
id: "campaigns.activity",
|
||||
surfaceId: "campaigns.widget.activity",
|
||||
title: "Campaign activity",
|
||||
description: "Recently changed campaigns and delivery state.",
|
||||
moduleId: "campaigns",
|
||||
category: "Communication",
|
||||
order: 50,
|
||||
defaultVisible: false,
|
||||
defaultSize: "medium",
|
||||
supportedSizes: ["medium", "wide"],
|
||||
anyOf: campaignRead,
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfiguration: {
|
||||
maxItems: 5,
|
||||
showCompleted: false
|
||||
},
|
||||
configurationFields: [
|
||||
{
|
||||
id: "maxItems",
|
||||
label: "Maximum campaigns",
|
||||
kind: "number",
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 1,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
id: "showCompleted",
|
||||
label: "Include completed campaigns",
|
||||
kind: "boolean"
|
||||
}
|
||||
],
|
||||
render: ({ settings, refreshKey, configuration }) =>
|
||||
createElement(CampaignActivityWidget, {
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const campaignModule: PlatformWebModule = {
|
||||
id: "campaigns",
|
||||
@@ -25,6 +76,15 @@ export const campaignModule: PlatformWebModule = {
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["files", "mail"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "campaigns.widget.activity",
|
||||
moduleId: "campaigns",
|
||||
kind: "section",
|
||||
label: "Campaign activity widget",
|
||||
order: 50
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 },
|
||||
{
|
||||
@@ -43,7 +103,10 @@ export const campaignModule: PlatformWebModule = {
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||
{ path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
|
||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) },
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
|
||||
uiCapabilities: {
|
||||
"dashboard.widgets": campaignDashboardWidgets
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -947,6 +947,83 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.campaign-postbox-delivery-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.campaign-postbox-target-modal {
|
||||
width: min(1040px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.campaign-postbox-target-body {
|
||||
max-height: min(76vh, 820px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.campaign-postbox-target-toolbar,
|
||||
.campaign-postbox-target-row-heading,
|
||||
.campaign-recipient-delivery-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.campaign-postbox-target-toolbar {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.campaign-postbox-target-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.campaign-postbox-target-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.campaign-postbox-target-row-heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.campaign-postbox-target-grid {
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.campaign-recipient-delivery-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.campaign-recipient-delivery-cell select {
|
||||
min-width: 0;
|
||||
flex: 1 1 170px;
|
||||
}
|
||||
|
||||
.campaign-recipient-delivery-cell .button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.campaign-postbox-delivery-settings,
|
||||
.campaign-postbox-target-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.campaign-recipient-delivery-cell {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.recipient-address-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
@@ -21,7 +21,7 @@ assert(
|
||||
"message previews use the central stacked Dialog with an accessible close label"
|
||||
);
|
||||
assert(
|
||||
styles.includes("height: min(780px, calc(100dvh - 48px));"),
|
||||
styles.includes("height: min(936px, calc(100dvh - 48px));"),
|
||||
"desktop previews use a stable responsive height"
|
||||
);
|
||||
assert(
|
||||
|
||||
@@ -106,7 +106,7 @@ assert(overlaySource.includes("{actions && <div"), "built-message footer actions
|
||||
assert(overlaySource.includes("<Dialog") && overlaySource.includes("closeLabel={closeLabel}"), "the preview uses the central Dialog and its accessible close label");
|
||||
|
||||
const styles = readFileSync("src/styles/campaign-workspace.css", "utf8");
|
||||
assert(styles.includes("height: min(780px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height");
|
||||
assert(styles.includes("height: min(936px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height");
|
||||
assert(styles.includes("height: calc(100dvh - 16px);"), "small viewports use the available dynamic viewport height");
|
||||
assert(styles.includes(".message-preview-modal .modal-footer"), "preview footer has a stable layout rule");
|
||||
assert(styles.includes(".attachment-linking-file-list.is-compact"), "compact attachment links use a bounded scroll surface");
|
||||
|
||||
Reference in New Issue
Block a user