feat: add governed postbox delivery and report hardening

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent f11c56e890
commit 5240749ae1
47 changed files with 5538 additions and 288 deletions

View File

@@ -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",

View File

@@ -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(

View 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;
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>);
}

View File

@@ -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",

View 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}`;
}

View File

@@ -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 = {

View File

@@ -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"

View File

@@ -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" />

View File

@@ -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
}
};

View File

@@ -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;

View File

@@ -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(

View File

@@ -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");