Refactor campaign recipient and review presentation

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent 5f7503598c
commit e689fdf495
30 changed files with 2175 additions and 1977 deletions
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
from govoplan_campaign.backend.retention import (
_apply_eml_retention,
_apply_raw_json_retention,
_apply_report_detail_retention,
)
def _querying(items: list[object]) -> MagicMock:
session = MagicMock()
session.query.return_value.order_by.return_value.all.return_value = items
session.query.return_value.filter.return_value.order_by.return_value.all.return_value = items
return session
def test_raw_json_retention_preserves_hash_for_final_version() -> None:
now = datetime.now(timezone.utc)
version = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
workflow_state="completed",
raw_json={"version": "1.0", "campaign": {"name": "Sensitive"}},
schema_version="1.0",
execution_snapshot={"campaign_json_sha256": "sealed-hash"},
)
session = _querying([version])
policy = SimpleNamespace(
store_raw_campaign_json=True,
raw_campaign_json_retention_days=1,
)
result = _apply_raw_json_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["redacted"] == 1
assert version.raw_json == {
"version": "1.0",
"_retention": {
"raw_json_redacted": True,
"redacted_at": now.isoformat(),
"original_sha256": "sealed-hash",
},
}
session.add.assert_called_once_with(version)
def test_raw_json_retention_does_not_redact_unfinished_version() -> None:
now = datetime.now(timezone.utc)
original = {"version": "1.0", "campaign": {"name": "Editable"}}
version = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
workflow_state="editing",
raw_json=original,
schema_version="1.0",
execution_snapshot=None,
)
session = _querying([version])
policy = SimpleNamespace(
store_raw_campaign_json=True,
raw_campaign_json_retention_days=1,
)
result = _apply_raw_json_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["skipped_not_final"] == 1
assert version.raw_json is original
session.add.assert_not_called()
def test_report_detail_retention_keeps_aggregate_evidence() -> None:
now = datetime.now(timezone.utc)
version = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
validation_summary={"ready": 4, "issues": [{"entry_id": "secret"}]},
build_summary={"built": 3, "jobs": [{"recipient": "person@example.test"}]},
)
session = _querying([version])
policy = SimpleNamespace(stored_report_detail_retention_days=1)
result = _apply_report_detail_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["summaries_redacted"] == 2
assert version.validation_summary["ready"] == 4
assert "issues" not in version.validation_summary
assert version.build_summary["built"] == 3
assert "jobs" not in version.build_summary
assert version.validation_summary["_retention"]["report_detail_redacted"] is True
session.add.assert_called_once_with(version)
def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None:
now = datetime.now(timezone.utc)
eml_path = tmp_path / "message.eml"
eml_path.write_bytes(b"message")
job = SimpleNamespace(
campaign_id="campaign-1",
updated_at=now - timedelta(days=10),
queue_status="draft",
send_status="smtp_accepted",
imap_status="appended",
eml_local_path=str(eml_path),
eml_storage_key="campaigns/message.eml",
)
session = _querying([job])
policy = SimpleNamespace(generated_eml_retention_days=1)
result = _apply_eml_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["files_deleted"] == 1
assert result["metadata_cleared"] == 1
assert not eml_path.exists()
assert job.eml_local_path is None
assert job.eml_storage_key is None
session.add.assert_called_once_with(job)
+3 -3
View File
@@ -19,9 +19,9 @@
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.14", "@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": ">=7.18.2 <8" "react-router": ">=8.3.0 <9"
}, },
"scripts": { "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", "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",
@@ -414,7 +414,7 @@ type ZipArchiveColumnContext = {
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] { function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
return [ return [
{ {
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start", id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
render: (archive, index) => render: (archive, index) =>
<button <button
type="button" type="button"
@@ -592,6 +592,7 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAv
id: "path", id: "path",
header: "i18n:govoplan-campaign.path.519e3913", header: "i18n:govoplan-campaign.path.519e3913",
width: "minmax(260px, 1fr)", width: "minmax(260px, 1fr)",
maxWidth: 720,
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
@@ -1,6 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Send } from "lucide-react"; import { Send } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router";
import { import {
DashboardWidgetList, DashboardWidgetList,
DismissibleAlert, DismissibleAlert,
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui"; import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
import { Link } from "react-router-dom"; import { Link } from "react-router";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
@@ -72,6 +72,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
id: "campaign", id: "campaign",
header: "i18n:govoplan-campaign.campaign.69390e16", header: "i18n:govoplan-campaign.campaign.69390e16",
width: "minmax(260px, 1.8fr)", width: "minmax(260px, 1.8fr)",
maxWidth: 720,
sortable: true, sortable: true,
filterable: true, filterable: true,
sticky: "start", sticky: "start",
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react"; import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
@@ -370,7 +370,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
value: (row) => String(row.recipient_email ?? "—") value: (row) => String(row.recipient_email ?? "—")
}, },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, { id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, 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: "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: "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: "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: "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") },
@@ -395,6 +395,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
id: "error", id: "error",
header: "i18n:govoplan-campaign.last_result.110b888b", header: "i18n:govoplan-campaign.last_result.110b888b",
width: "minmax(220px, 1fr)", width: "minmax(220px, 1fr)",
maxWidth: 720,
resizable: true, resizable: true,
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>, render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
value: (row) => String(row.last_error ?? "—") value: (row) => String(row.last_error ?? "—")
@@ -631,6 +632,7 @@ function PostboxTargetEvidenceSection({ targets }: { targets: unknown[] }) {
header: "Frozen Postbox target", header: "Frozen Postbox target",
width: "minmax(240px, 1fr)", width: "minmax(240px, 1fr)",
minWidth: 220, minWidth: 220,
maxWidth: 640,
resizable: true, resizable: true,
filterable: true, filterable: true,
value: (row) => String(row.address ?? row.name ?? row.postbox_id ?? "—"), value: (row) => String(row.address ?? row.name ?? row.postbox_id ?? "—"),
@@ -728,7 +730,7 @@ function AttachmentEvidenceSection({ attachments }: {attachments: unknown[];}) {
{ id: "rule", header: "Rule", width: 180, resizable: true, sortable: true, filterable: true, value: (row) => row.rule }, { id: "rule", header: "Rule", width: 180, resizable: true, sortable: true, filterable: true, value: (row) => row.rule },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status }, { id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
{ id: "delivery", header: "Attachment output", width: 220, resizable: true, filterable: true, value: (row) => row.delivery }, { id: "delivery", header: "Attachment output", width: 220, resizable: true, filterable: true, value: (row) => row.delivery },
{ id: "file", header: "Frozen file", width: "minmax(240px, 1fr)", minWidth: 220, resizable: true, filterable: true, value: (row) => row.file }, { id: "file", header: "Frozen file", width: "minmax(240px, 1fr)", minWidth: 220, maxWidth: 640, resizable: true, filterable: true, value: (row) => row.file },
{ {
id: "version", id: "version",
header: "Managed version", header: "Managed version",
@@ -886,7 +888,7 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";
] : []), ] : []),
{ 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: "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: "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 ?? 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> } { id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, maxWidth: 720, 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 ( return (
@@ -1,5 +1,5 @@
import { lazy, useEffect, useState } from "react"; import { lazy, useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom"; import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router";
import { import {
ConcurrencyConflictProvider, ConcurrencyConflictProvider,
useGuardedNavigate useGuardedNavigate
File diff suppressed because it is too large Load Diff
+17 -171
View File
@@ -4,7 +4,6 @@ import {
Check, Check,
FlaskConical, FlaskConical,
PackageCheck, PackageCheck,
Search,
Send, Send,
ShieldCheck } from ShieldCheck } from
"lucide-react"; "lucide-react";
@@ -37,15 +36,15 @@ import {
"../../api/campaigns"; "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui"; import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { i18nMessage } from "@govoplan/core-webui"; import { i18nMessage } from "@govoplan/core-webui";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import CampaignMessagePreviewOverlay from "./components/MessagePreviewOverlay";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
@@ -59,8 +58,7 @@ import {
isFinalLockedVersion, isFinalLockedVersion,
isHistoricalCampaignVersion, isHistoricalCampaignVersion,
isUserLockedVersion, isUserLockedVersion,
isVersionReadyForDelivery, isVersionReadyForDelivery } from
stringifyPreview } from
"./utils/campaignView"; "./utils/campaignView";
import { deliveryModeLabel } from "./utils/deliveryMode"; import { deliveryModeLabel } from "./utils/deliveryMode";
import { getText } from "./utils/draftEditor"; import { getText } from "./utils/draftEditor";
@@ -91,22 +89,26 @@ import {
storedMessageReviewState storedMessageReviewState
} from "./review/builtMessageQuery"; } from "./review/builtMessageQuery";
import { import {
countResolvedAttachments,
formatAddressList, formatAddressList,
numberFrom numberFrom
} from "./review/reviewFormatters"; } from "./review/reviewFormatters";
import {
builtMessageColumns,
deliveryControlProgressMessage,
deliveryPolicySourceLabel,
imapAppendResultColumns,
imapDiagnosticColumns,
mockMailboxColumns,
mockMessageAttachments,
mockMessageMetaItems,
mockSendResultColumns,
sendResultColumns,
synchronousSendReason
} from "./review/reviewPresentation";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | ""; type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" },
{ value: "warning", label: "i18n:govoplan-campaign.warning.e9c45563" },
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
{ value: "blocked", label: "i18n:govoplan-campaign.blocked.99613c74" },
{ value: "excluded", label: "i18n:govoplan-campaign.excluded.9804952b" }];
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"]; const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
export default function ReviewSendPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { export default function ReviewSendPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
@@ -1922,159 +1924,3 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
</div>); </div>);
} }
function builtMessageColumns(
openMessage: (row: Record<string, unknown>) => void,
reviewedKeys: Set<string>)
: DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
{ 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: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
value: (row) => String(row.validation_status ?? "unknown")
},
{
id: "postboxTargets",
header: "Postbox targets",
width: 145,
align: "right",
sortable: true,
filterType: "integer",
value: (row) => Number(row.postbox_target_count ?? 0),
render: (row) => {
const count = Number(row.postbox_target_count ?? 0);
return count > 0 ? (
<StatusBadge status="info" label={`${count} frozen`} />
) : (
<span className="muted"></span>
);
}
},
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
}
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "skipped", label: "i18n:govoplan-campaign.skipped.5a000ad7" },
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" }];
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.status ?? "info") },
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" },
{ id: "smtp", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? "—") }];
}
function sendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
const statuses: DataGridListOption[] = [
{ value: "smtp_accepted", label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603" },
{ value: "already_accepted", label: "i18n:govoplan-campaign.already_accepted.826e68f2" },
{ value: "outcome_unknown", label: "i18n:govoplan-campaign.outcome_unknown.6e929fca" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "cancelled", label: "i18n:govoplan-campaign.cancelled.a1bf92ef" },
{ value: "not_claimed", label: "i18n:govoplan-campaign.not_claimed.aa712c1b" },
{ value: "dry_run", label: "i18n:govoplan-campaign.dry_run.485a3d15" }];
return [
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: statuses, display: "pill" }, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
{ id: "job", header: "i18n:govoplan-campaign.job.30c8cb83", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? row.version_id ?? "—") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", width: "minmax(320px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) }];
}
function imapAppendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sticky: "start", sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
{ id: "job", header: "i18n:govoplan-campaign.job.30c8cb83", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? "-") },
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 190, sortable: true, filterable: true, value: (row) => String(row.folder ?? "-") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", width: "minmax(300px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) }];
}
function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
}
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "smtp", label: "i18n:govoplan-campaign.smtp.efff9cca" },
{ value: "imap_append", label: "i18n:govoplan-campaign.imap_append.8c0d9e96" }];
return [
{ id: "kind", header: "i18n:govoplan-campaign.kind.e00ac23f", width: 125, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.kind ?? "mock") },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => void openMessage(String(row.id ?? "")) }]} /> }];
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "i18n:govoplan-campaign.from.3f66052a", value: message.from_header || message.envelope_from || "—" },
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: message.cc_header || null },
{ label: "i18n:govoplan-campaign.bcc.8431acad", value: message.bcc_header || null },
{ label: "i18n:govoplan-campaign.kind.e00ac23f", value: message.kind || "—" },
{ label: "i18n:govoplan-campaign.folder.30baa249", value: message.folder || "—" },
{ label: "i18n:govoplan-campaign.message_id.fa0e4fdd", value: message.message_id || "—" },
{ label: "i18n:govoplan-campaign.size.b7152342", value: `${message.size_bytes || 0} bytes` }];
}
function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,
sizeBytes: attachment.size_bytes ?? undefined
}));
}
function synchronousSendReason(option: Record<string, unknown>): string {
const configuredMessage = String(option.message ?? "").trim();
if (configuredMessage) return configuredMessage;
switch (String(option.reason ?? "")) {
case "recipient_limit_exceeded":return "i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a";
case "no_eligible_recipient_jobs":return "i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c";
case "version_not_ready":return "i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc";
case "policy_configuration_invalid":return "i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8";
default:return "i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e";
}
}
function deliveryControlProgressMessage(action: "pause" | "resume" | "cancel"): string {
if (action === "pause") return "i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58";
if (action === "resume") return "i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2";
return "i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47";
}
function deliveryPolicySourceLabel(source: string): string {
if (source === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
if (source === "deployment") return "i18n:govoplan-campaign.deployment.327a55f8";
if (source === "deployment_default") return "i18n:govoplan-campaign.deployment_default.aa39f7b4";
if (source === "deployment_ceiling") return "i18n:govoplan-campaign.deployment_ceiling.abbec75b";
return "i18n:govoplan-campaign.not_available.d1a17af1";
}
@@ -4,9 +4,11 @@ import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui"; import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui";
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
@@ -35,7 +37,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const [attachmentPreviewError, setAttachmentPreviewError] = useState(""); const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
const subjectRef = useRef<HTMLInputElement | null>(null); const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null); const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<HTMLTextAreaElement | null>(null); const htmlRef = useRef<WysiwygEditorHandle | null>(null);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
@@ -160,8 +162,12 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
function insertPlaceholder(namespace: TemplateNamespace, name: string) { function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return; if (locked) return;
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor; const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
const token = `{{${namespace}:${name}}}`; const token = `{{${namespace}:${name}}}`;
if (target === "html") {
htmlRef.current?.insertToken({ value: token, label: `${namespace}:${name}` });
return;
}
const element = target === "subject" ? subjectRef.current : textRef.current;
const currentText = getText(template, target); const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length; const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length; const end = element?.selectionEnd ?? currentText.length;
@@ -300,16 +306,19 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
</FormField> </FormField>
} }
{visibleBodyEditor === "html" && {visibleBodyEditor === "html" &&
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37"> <div className="form-field">
<textarea <FieldLabel className="form-label">i18n:govoplan-campaign.html_body.77b5ba37</FieldLabel>
<WysiwygEditor
ref={htmlRef} ref={htmlRef}
rows={16}
value={getText(template, "html")} value={getText(template, "html")}
disabled={locked} disabled={locked}
ariaLabel="i18n:govoplan-campaign.html_body.77b5ba37"
minHeight={344}
sourceRows={16}
onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}} onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}}
onChange={(event) => patchTemplateText("html", event.target.value)} /> onChange={(value) => patchTemplateText("html", value)} />
</FormField> </div>
} }
<div className="button-row template-editor-actions"> <div className="button-row template-editor-actions">
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button> <Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
@@ -305,6 +305,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
id: "file_filter", id: "file_filter",
header: "i18n:govoplan-campaign.file_pattern.86320b07", header: "i18n:govoplan-campaign.file_pattern.86320b07",
width: "minmax(260px, 1fr)", width: "minmax(260px, 1fr)",
maxWidth: 640,
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
@@ -372,6 +372,7 @@ export default function CampaignAccessCard({
header: "i18n:govoplan-campaign.shared_with.6203f449", header: "i18n:govoplan-campaign.shared_with.6203f449",
width: "minmax(220px, 1fr)", width: "minmax(220px, 1fr)",
minWidth: 190, minWidth: 190,
maxWidth: 560,
resizable: true, resizable: true,
sticky: "start", sticky: "start",
sortable: true, sortable: true,
@@ -1,6 +1,6 @@
import { i18nMessage, useGuardedNavigate } from "@govoplan/core-webui"; import { i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react"; import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns"; import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { formatDateTime } from "../utils/campaignView"; import { formatDateTime } from "../utils/campaignView";
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router";
import { useDeltaWatermarks } from "@govoplan/core-webui"; import { useDeltaWatermarks } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types"; import type { ApiSettings } from "../../../types";
import { import {
@@ -367,6 +367,7 @@ function AddressSourceRecipientPreviewGrid({
header: "Name", header: "Name",
width: "minmax(180px, 1fr)", width: "minmax(180px, 1fr)",
minWidth: 160, minWidth: 160,
maxWidth: 420,
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
@@ -377,6 +378,7 @@ function AddressSourceRecipientPreviewGrid({
header: "Email", header: "Email",
width: "minmax(220px, 1.2fr)", width: "minmax(220px, 1.2fr)",
minWidth: 190, minWidth: 190,
maxWidth: 520,
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
@@ -0,0 +1,639 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react";
import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
import {
Button,
Dialog,
DismissibleAlert,
TableActionGroup,
ToggleSwitch,
addressesFromValue,
dedupeAddresses,
moveArrayItem,
parseMailboxAddressText,
usePlatformLanguage,
type MailboxAddress
} from "@govoplan/core-webui";
import { getBool } from "../utils/draftEditor";
export const recipientHeaderRows = [
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
{ key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", toggleKey: "allow_individual_bcc", toggleLabel: "i18n:govoplan-campaign.allow_individual_bcc.a932d94b", addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_global_bcc_recipients_configured.86ef64ab" }] as
const;
export type RecipientAddressKey = "to" | "cc" | "bcc";
export type AddressFieldKey = RecipientAddressKey | "from" | "reply_to";
export type HeaderAddressEditorState = {
title: string;
columns: EntryAddressColumn[];
} | null;
export type EntryAddressColumn = {
key: AddressFieldKey;
label: string;
allowMultiple: boolean;
addLabel: string;
emptyText: string;
mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to";
};
export const recipientAddressOverlayColumns: EntryAddressColumn[] = [
{ key: "from", label: "i18n:govoplan-campaign.from.3f66052a", allowMultiple: false, addLabel: "i18n:govoplan-campaign.set_from.11fe7396", emptyText: "i18n:govoplan-campaign.uses_global_from.4cf8e6ce" },
{ key: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_reply_to.84b195f4", emptyText: "i18n:govoplan-campaign.uses_global_reply_to.72c8d931", mergeKey: "merge_reply_to" },
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_to.768a148b", emptyText: "i18n:govoplan-campaign.no_recipient_address.1301a488", mergeKey: "merge_to" },
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_cc.5463a081", mergeKey: "merge_cc" },
{ key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_bcc.48cade21", mergeKey: "merge_bcc" }];
export type RecipientAddressEditorDialogProps = {
entry: Record<string, unknown>;
index: number;
locked: boolean;
recipientsSection: Record<string, unknown>;
entryDefaults: Record<string, unknown>;
onSave: (values: HeaderAddressValues, merges: EntryAddressMergeValues) => void;
onClose: () => void;
};
export type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
export type EntryAddressMergeValues = Partial<Record<NonNullable<EntryAddressColumn["mergeKey"]>, boolean>>;
type AddressHeaderControlProps = {
columns: EntryAddressColumn[];
values: HeaderAddressValues;
emptyText: string;
disabled: boolean;
onEdit: () => void;
onCopy: () => void;
};
export function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, onCopy }: AddressHeaderControlProps) {
const { translateText } = usePlatformLanguage();
const textRef = useRef<HTMLDivElement | null>(null);
const [availableWidth, setAvailableWidth] = useState(0);
const segments = useMemo(() => addressDisplaySegments(columns, values), [columns, values]);
const visibleSegments = useMemo(() => fitAddressSegments(segments, availableWidth), [availableWidth, segments]);
const hiddenCount = Math.max(0, segments.length - visibleSegments.length);
const hasAddresses = segments.length > 0;
const copiedText = formatAddressCollectionForClipboard(columns, values);
useEffect(() => {
const element = textRef.current;
if (!element) return;
const updateWidth = () => setAvailableWidth(element.clientWidth);
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div className="recipient-address-header-control">
<div className={`recipient-address-header-summary ${hasAddresses ? "" : "is-empty"}`}>
<div className="recipient-address-header-text" ref={textRef}>
{hasAddresses ?
<>
<span>{visibleSegments.join(", ")}</span>
{hiddenCount > 0 && <span className="recipient-extra-bubble">+{hiddenCount}</span>}
</> :
<span>{translateText(emptyText)}</span>
}
</div>
<TableActionGroup actions={[
{ id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
{ id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
]} />
</div>
</div>);
}
export type HeaderAddressEditorDialogProps = {
title: string;
columns: EntryAddressColumn[];
values: HeaderAddressValues;
locked: boolean;
onSave: (values: HeaderAddressValues) => void;
onClose: () => void;
};
export function HeaderAddressEditorDialog({ title, columns, values, locked, onSave, onClose }: HeaderAddressEditorDialogProps) {
const { translateText } = usePlatformLanguage();
const columnKeys = new Set(columns.map((column) => column.key));
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() => cloneAddressValues(columns, values));
const [validationError, setValidationError] = useState("");
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
if (groups.length === 0) return false;
setDraftValues((current) => mergeAddressGroups(current, groups));
setValidationError("");
return true;
}
function save() {
const prepared = prepareAddressValues(columns, draftValues, translateText);
if (prepared.error) {
setValidationError(prepared.error);
return;
}
onSave(prepared.values);
}
return (
<Dialog
open
title={title}
className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body"
onClose={onClose}
footer={<>
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
</>}>
<div className="recipient-address-editor">
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
{columns.map((column) =>
<RecipientAddressCategoryEditor
key={column.key}
column={column}
addresses={draftValues[column.key] ?? []}
merge={false}
locked={locked}
onAddressesChange={(addresses) => {
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
setValidationError("");
}}
onPasteAddresses={applyPastedAddresses} />
)}
</div>
</Dialog>);
}
export function RecipientAddressEditorDialog({
entry,
index,
locked,
recipientsSection,
entryDefaults,
onSave,
onClose
}: RecipientAddressEditorDialogProps) {
const { translateText } = usePlatformLanguage();
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
const availableKeys = new Set(availableColumns.map((column) => column.key));
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() =>
Object.fromEntries(availableColumns.map((column) => [column.key, getEntryAddresses(entry, column.key).map(cloneMailboxAddress)]))
);
const [draftMerges, setDraftMerges] = useState<EntryAddressMergeValues>(() =>
Object.fromEntries(
availableColumns
.filter((column) => column.mergeKey)
.map((column) => [column.mergeKey!, getEntryMerge(entry, entryDefaults, column.mergeKey!)])
)
);
const [validationError, setValidationError] = useState("");
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
if (groups.length === 0) return false;
setDraftValues((current) => mergeAddressGroups(current, groups));
setValidationError("");
return true;
}
function save() {
const prepared = prepareAddressValues(availableColumns, draftValues, translateText);
if (prepared.error) {
setValidationError(prepared.error);
return;
}
onSave(prepared.values, draftMerges);
}
return (
<Dialog
open
title={`Recipient ${index + 1} - addresses`}
className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body"
onClose={onClose}
footer={<>
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
</>}>
<div className="recipient-address-editor">
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
{availableColumns.length === 0 &&
<p className="muted">No individual recipient address fields are enabled.</p>
}
{availableColumns.map((column) =>
<RecipientAddressCategoryEditor
key={column.key}
column={column}
addresses={draftValues[column.key] ?? []}
merge={column.mergeKey ? Boolean(draftMerges[column.mergeKey]) : false}
locked={locked}
onAddressesChange={(addresses) => {
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
setValidationError("");
}}
onMergeChange={column.mergeKey ? (merge) => {
setDraftMerges((current) => ({ ...current, [column.mergeKey!]: merge }));
} : undefined}
onPasteAddresses={applyPastedAddresses} />
)}
</div>
</Dialog>);
}
type RecipientAddressCategoryEditorProps = {
column: EntryAddressColumn;
addresses: MailboxAddress[];
merge: boolean;
locked: boolean;
onAddressesChange: (addresses: MailboxAddress[]) => void;
onMergeChange?: (merge: boolean) => void;
onPasteAddresses?: (targetKey: AddressFieldKey, text: string) => boolean;
};
function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAddressesChange, onMergeChange, onPasteAddresses }: RecipientAddressCategoryEditorProps) {
const { translateText } = usePlatformLanguage();
const [draftAddresses, setDraftAddresses] = useState<MailboxAddress[]>(() => normalizeEditorAddressRows(column, addresses));
const canAdd = !locked && column.allowMultiple;
const translatedAddLabel = translateText(column.addLabel);
const translatedMoveUpLabel = "Move address up";
const translatedMoveDownLabel = "Move address down";
const translatedRemoveLabel = "Remove address";
useEffect(() => {
setDraftAddresses(normalizeEditorAddressRows(column, addresses));
}, [addresses, column]);
function commitAddresses(nextAddresses: MailboxAddress[]) {
setDraftAddresses(nextAddresses);
onAddressesChange(nextAddresses);
}
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
commitAddresses(draftAddresses.map((address, currentIndex) => currentIndex === addressIndex ? { ...address, ...patch } : address));
}
function addAddress(afterIndex = draftAddresses.length - 1) {
if (!column.allowMultiple) return;
const insertIndex = Math.max(0, Math.min(afterIndex + 1, draftAddresses.length));
const nextAddresses = [
...draftAddresses.slice(0, insertIndex),
{ name: "", email: "" },
...draftAddresses.slice(insertIndex)];
commitAddresses(nextAddresses);
}
function removeAddress(addressIndex: number) {
commitAddresses(draftAddresses.filter((_, currentIndex) => currentIndex !== addressIndex));
}
function moveAddress(addressIndex: number, targetIndex: number) {
if (addressIndex === targetIndex) return;
commitAddresses(moveArrayItem(draftAddresses, addressIndex, targetIndex));
}
function handlePaste(event: ClipboardEvent) {
if (locked || !onPasteAddresses) return;
const text = event.clipboardData.getData("text/plain");
if (onPasteAddresses(column.key, text)) event.preventDefault();
}
return (
<section className="recipient-address-category" tabIndex={locked ? undefined : 0} onPaste={handlePaste}>
<div className="recipient-address-category-header">
<h3>{column.label}</h3>
<div className="recipient-address-category-controls">
{onMergeChange ?
<ToggleSwitch
label="Merge mode"
inactiveLabel="Overwrite"
activeLabel="Merge"
checked={merge}
disabled={locked}
onChange={onMergeChange} /> :
null}
</div>
</div>
<div className="recipient-address-lines">
{column.allowMultiple && draftAddresses.length === 0 &&
<div className="recipient-address-empty-row">
<p className="muted recipient-address-empty">{column.emptyText}</p>
<TableActionGroup
className="data-grid-empty-row-actions"
actions={[{
id: "add",
label: translatedAddLabel,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled: !canAdd,
onClick: () => addAddress()
}]}
/>
</div>
}
{draftAddresses.map((address, addressIndex) =>
<div className="recipient-address-edit-line" key={`${column.key}-${addressIndex}`}>
<input
value={address.name ?? ""}
disabled={locked}
placeholder="Name"
aria-label={`${column.label} name ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { name: event.target.value })} />
<input
value={address.email}
disabled={locked}
placeholder="Email"
type="email"
aria-label={`${column.label} email ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
{column.allowMultiple &&
<TableActionGroup
className="recipient-address-line-actions"
actions={[
{ id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
{ id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
{ id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
{ id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
]}
/>
}
</div>
)}
</div>
</section>);
}
export function recipientAddressSummary(entry: Record<string, unknown>, translateText: (value: string) => string): {primary: string;badges: string[];empty: boolean;} {
const to = getEntryAddresses(entry, "to");
const primaryAddress = to[0] ?? null;
const badges = [
addressCountBadge(Math.max(0, to.length - 1), "To"),
addressCountBadge(getEntryAddresses(entry, "cc").length, "CC"),
addressCountBadge(getEntryAddresses(entry, "bcc").length, "BCC"),
addressCountBadge(getEntryAddresses(entry, "reply_to").length, "Reply-To"),
addressCountBadge(getEntryAddresses(entry, "from").length, "From")].
filter((badge): badge is string => Boolean(badge));
if (!primaryAddress) {
return {
primary: translateText("i18n:govoplan-campaign.no_recipient_address.1301a488"),
badges,
empty: true
};
}
return {
primary: formatMailboxAddress(primaryAddress),
badges,
empty: false
};
}
export function recipientAddressFilterValue(entry: Record<string, unknown>): string {
return recipientAddressOverlayColumns.
flatMap((column) => getEntryAddresses(entry, column.key)).
map(formatMailboxAddress).
join(", ");
}
export function hiddenRecipientAddressMatch(
entry: Record<string, unknown>,
filterValue: string
): {label: string;address: string;} | null {
const query = filterValue.trim().toLowerCase();
if (!query) return null;
const primary = getEntryAddresses(entry, "to")[0];
if (primary && formatMailboxAddress(primary).toLowerCase().includes(query)) return null;
const labels: Record<AddressFieldKey, string> = {
from: "From",
reply_to: "Reply-To",
to: "To",
cc: "CC",
bcc: "BCC"
};
for (const column of recipientAddressOverlayColumns) {
const addresses = getEntryAddresses(entry, column.key);
const hiddenAddresses = column.key === "to" ? addresses.slice(1) : addresses;
const match = hiddenAddresses.find((address) => formatMailboxAddress(address).toLowerCase().includes(query));
if (match) return { label: labels[column.key], address: formatMailboxAddress(match) };
}
return null;
}
function addressCountBadge(count: number, label: string): string | null {
if (count <= 0) return null;
return `+${count} ${label}`;
}
export function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
const column = recipientAddressOverlayColumns.find((item) => item.key === key);
if (!column) throw new Error(`Unknown address column ${key}`);
return column;
}
function cloneMailboxAddress(address: MailboxAddress): MailboxAddress {
return { name: address.name ?? "", email: address.email ?? "" };
}
function cloneAddressValues(columns: EntryAddressColumn[], values: HeaderAddressValues): HeaderAddressValues {
return Object.fromEntries(
columns.map((column) => [column.key, (values[column.key] ?? []).map(cloneMailboxAddress)])
) as HeaderAddressValues;
}
function mergeAddressGroups(
values: HeaderAddressValues,
groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>
): HeaderAddressValues {
const nextValues = cloneAddressValues(recipientAddressOverlayColumns, values);
for (const group of groups) {
nextValues[group.key] = dedupeAddresses([
...(nextValues[group.key] ?? []),
...group.addresses.map(cloneMailboxAddress)
]);
}
return nextValues;
}
function prepareAddressValues(
columns: EntryAddressColumn[],
values: HeaderAddressValues,
translateText: (value: string) => string
): {values: HeaderAddressValues;error: string;} {
const prepared: HeaderAddressValues = {};
for (const column of columns) {
const addresses: MailboxAddress[] = [];
for (const address of values[column.key] ?? []) {
const name = String(address.name ?? "").trim();
const email = String(address.email ?? "").trim();
if (!name && !email) continue;
if (!email) {
return {
values: prepared,
error: `${translateText(column.label)}: enter an email address or remove the incomplete row.`
};
}
addresses.push({ name, email });
}
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses) : addresses.slice(0, 1);
}
return { values: prepared, error: "" };
}
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
const rows = addresses.map(cloneMailboxAddress);
if (column.allowMultiple) return rows;
return rows.length > 0 ? rows.slice(0, 1) : [{ name: "", email: "" }];
}
function formatMailboxAddress(address: MailboxAddress): string {
const name = String(address.name ?? "").trim();
const email = String(address.email ?? "").trim();
if (name && email) return `${name} <${email}>`;
return email || name;
}
export function entryWithAddressList(entry: Record<string, unknown>, key: AddressFieldKey, addresses: MailboxAddress[]): Record<string, unknown> {
if (key === "from") return { ...entry, from: addresses.slice(0, 1) };
const nextEntry = { ...entry, [key]: addresses };
if (key === "to") {
const address = addresses[0] ?? { name: "", email: "" };
return {
...nextEntry,
name: address.name ?? "",
email: address.email
};
}
return nextEntry;
}
export function headerAddressValues(columns: EntryAddressColumn[], recipientsSection: Record<string, unknown>): HeaderAddressValues {
return Object.fromEntries(columns.map((column) => [
column.key,
column.key === "from" ? addressesFromValue(recipientsSection.from).slice(0, 1) : addressesFromValue(recipientsSection[column.key])]
)) as HeaderAddressValues;
}
function addressDisplaySegments(columns: EntryAddressColumn[], values: HeaderAddressValues): string[] {
return columns.flatMap((column) =>
(values[column.key] ?? []).map((address) => `${addressDisplayPrefix(column.key)}${formatMailboxAddress(address)}`)
).filter(Boolean);
}
function fitAddressSegments(segments: string[], availableWidth: number): string[] {
if (segments.length <= 1 || availableWidth <= 0) return segments;
if (measureAddressTextWidth(segments.join(", ")) <= availableWidth) return segments;
const maxWidth = Math.max(120, availableWidth - 42);
let usedWidth = 0;
const visible: string[] = [];
for (const segment of segments) {
const estimatedWidth = measureAddressTextWidth(`${visible.length > 0 ? ", " : ""}${segment}`);
if (visible.length > 0 && usedWidth + estimatedWidth > maxWidth) break;
visible.push(segment);
usedWidth += estimatedWidth;
}
return visible.length > 0 ? visible : segments.slice(0, 1);
}
function measureAddressTextWidth(value: string): number {
if (typeof document === "undefined") return value.length * 6.6;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return value.length * 6.6;
context.font = "13px system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif";
return context.measureText(value).width;
}
export function formatAddressCollectionForClipboard(columns: EntryAddressColumn[], values: HeaderAddressValues): string {
return addressDisplaySegments(columns, values).join(", ");
}
function addressDisplayPrefix(key: AddressFieldKey): string {
if (key === "from") return "From: ";
if (key === "reply_to") return "Reply-To: ";
if (key === "to") return "To: ";
if (key === "cc") return "CC: ";
return "BCC: ";
}
function parsePastedAddressGroups(targetKey: AddressFieldKey, text: string): Array<{key: AddressFieldKey;addresses: MailboxAddress[];}> {
const tokens = splitPastedAddressText(text);
const hasPrefixedToken = tokens.some((token) => Boolean(prefixedAddressToken(token)));
const grouped = new Map<AddressFieldKey, MailboxAddress[]>();
for (const token of tokens) {
const prefixed = prefixedAddressToken(token);
const key = prefixed?.key ?? (hasPrefixedToken ? "to" : targetKey);
const addressText = prefixed?.text ?? token;
const address = parseMailboxAddressText(addressText);
if (!address?.email) continue;
grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address]));
}
return [...grouped.entries()].map(([key, addresses]) => ({ key, addresses }));
}
function splitPastedAddressText(text: string): string[] {
return text.
replace(/\r/g, "\n").
split(/\n|;/).
flatMap((part) => part.split(",")).
map((part) => part.trim()).
filter(Boolean);
}
function prefixedAddressToken(token: string): {key: AddressFieldKey;text: string;} | null {
const match = token.match(/^(from|sender|reply[-_\s]?to|to|cc|bcc)\s*:\s*(.+)$/i);
if (!match) return null;
const key = addressKeyFromPrefix(match[1]);
return key ? { key, text: match[2].trim() } : null;
}
function addressKeyFromPrefix(prefix: string): AddressFieldKey | null {
const normalized = prefix.toLowerCase().replace(/[-_\s]/g, "");
if (normalized === "from" || normalized === "sender") return "from";
if (normalized === "replyto") return "reply_to";
if (normalized === "to") return "to";
if (normalized === "cc") return "cc";
if (normalized === "bcc") return "bcc";
return null;
}
function getEntryMerge(entry: Record<string, unknown>, defaults: Record<string, unknown>, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>): boolean {
const legacyKey = mergeKey.replace("merge_", "combine_");
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
return true;
}
function entryAddressEnabled(recipientsSection: Record<string, unknown>, key: EntryAddressColumn["key"]): boolean {
return getBool(recipientsSection, `allow_individual_${key}`, key === "to");
}
export function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
if (key === "to") {
const explicit = addressesFromValue(entry.to);
return explicit.length ? explicit : fallbackRecipientAddress(entry);
}
if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
if (key === "reply_to") return addressesFromValue(entry.reply_to);
return addressesFromValue(entry[key]);
}
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return direct?.email ? [direct] : [];
}
@@ -0,0 +1,832 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../../types";
import {
createRecipientImportMappingProfile,
listRecipientImportMappingProfiles,
updateRecipientImportMappingProfile,
type RecipientImportMappingProfilePayload
} from "../../../api/campaigns";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
FileDropZone,
FormField,
ToggleSwitch,
i18nMessage,
usePlatformUiCapability,
type DataGridColumn,
type FilesFileExplorerUiCapability
} from "@govoplan/core-webui";
import { getDraftFields } from "../utils/fieldDefinitions";
import type { AttachmentBasePath } from "../utils/attachments";
import {
applyRecipientMappingProfile,
buildImportTable,
buildImportTableFromRows,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile as buildRecipientMappingProfile,
defaultColumnMappings,
matchRecipientMappingProfiles,
recipientImportHeaderFingerprints,
xlsxSheetsFromArrayBuffer,
type CsvDelimiter,
type ImportedAddress,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview,
type RecipientImportProvenance,
type RecipientImportSourceType,
type RecipientImportSpreadsheetSheet,
type RecipientMappingProfile,
type RecipientMappingProfileMatch
} from "../utils/bulkImport";
import {
bulkLinkFilesToCampaign,
renderedImportPatterns,
resolveImportedAttachmentLinks,
type ImportFileLinkCapability,
type ImportFileLinkResolution
} from "../utils/fileLinking";
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
existingEntries: Record<string, unknown>[];
existingFields: ReturnType<typeof getDraftFields>;
defaultAttachmentBasePath: AttachmentBasePath | null;
onCancel: () => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
};
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
const allRecipientImportSteps: Array<{id: RecipientImportStepId;label: string;}> = [
{ id: "upload", label: "i18n:govoplan-campaign.upload.8bdf057f" },
{ id: "parse", label: "i18n:govoplan-campaign.parse.b7e45a36" },
{ id: "map", label: "i18n:govoplan-campaign.map.ab478f3e" },
{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
{ id: "files", label: "i18n:govoplan-campaign.files.6ce6c512" }];
const recipientColumnKindOptions: Array<{value: RecipientColumnKind;label: string;}> = [
{ value: "ignore", label: "i18n:govoplan-campaign.ignore.98f55db5" },
{ value: "id", label: "i18n:govoplan-campaign.id.89f89c02" },
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
{ value: "name", label: "i18n:govoplan-campaign.display_name.c7874aaa" },
{ value: "from", label: "i18n:govoplan-campaign.from.3f66052a" },
{ value: "to", label: "i18n:govoplan-campaign.to.ae79ea1e" },
{ value: "cc", label: "i18n:govoplan-campaign.cc.c5a976de" },
{ value: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3" },
{ value: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667" },
{ value: "field", label: "i18n:govoplan-campaign.existing_field.681f2b09" },
{ value: "new_field", label: "i18n:govoplan-campaign.new_field.57fc8a2e" },
{ value: "attachment_pattern", label: "i18n:govoplan-campaign.attachment_pattern.59649690" }];
const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55;
const RECIPIENT_IMPORT_ENCODINGS = [
{ value: "utf-8", label: "i18n:govoplan-campaign.utf_8.663b90c8" },
{ value: "windows-1252", label: "i18n:govoplan-campaign.windows_1252.6944986d" },
{ value: "iso-8859-1", label: "i18n:govoplan-campaign.iso_8859_1.e4d77380" },
{ value: "utf-16le", label: "i18n:govoplan-campaign.utf_16_le.8d74294c" },
{ value: "utf-16be", label: "i18n:govoplan-campaign.utf_16_be.1de115c7" }];
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const fileLinkCapability = useMemo<ImportFileLinkCapability | null>(() => {
if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null;
return {
listFiles: filesFileExplorer.listFiles,
resolveFilePatterns: filesFileExplorer.resolveFilePatterns,
shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget
};
}, [filesFileExplorer]);
const recipientImportSteps = useMemo(
() => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"),
[fileLinkCapability]
);
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
const [csvText, setCsvText] = useState("");
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
const [filename, setFilename] = useState("");
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState("utf-8");
const [workbookSheets, setWorkbookSheets] = useState<RecipientImportSpreadsheetSheet[]>([]);
const [selectedSheetName, setSelectedSheetName] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
const [headerRows, setHeaderRows] = useState(1);
const [quoted, setQuoted] = useState(true);
const [valueSeparators, setValueSeparators] = useState(",;|");
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
const [mappingProfiles, setMappingProfiles] = useState<RecipientMappingProfile[]>([]);
const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false);
const [mappingProfileError, setMappingProfileError] = useState("");
const [mappingProfileNotice, setMappingProfileNotice] = useState("");
const [fileError, setFileError] = useState("");
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
const [fileLinkResolving, setFileLinkResolving] = useState(false);
const [fileLinking, setFileLinking] = useState(false);
const [fileLinkError, setFileLinkError] = useState("");
const [fileLinkNotice, setFileLinkNotice] = useState("");
const selectedSheet = useMemo(
() => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null,
[selectedSheetName, workbookSheets]
);
const hasContent = sourceType === "xlsx" ?
Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim()))) :
csvText.trim().length > 0;
const table = useMemo(
() => {
if (!hasContent) return null;
if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null;
return buildImportTable(csvText, { delimiter, headerRows, quoted });
},
[csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType]
);
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
const mappingProfileMatches = useMemo(
() => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [],
[mappingProfiles, table]
);
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
const preview = useMemo(
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
[existingEntries, existingFields, mappings, table, valueSeparators]
);
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
const isLastStep = activeStepIndex === recipientImportSteps.length - 1;
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
useEffect(() => {
let cancelled = false;
setMappingProfileError("");
void listRecipientImportMappingProfiles(settings).
then((profiles) => {
if (!cancelled) setMappingProfiles(profiles);
}).
catch((err) => {
if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err));
});
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!fileBuffer || sourceType !== "csv") return;
try {
setCsvText(decodeImportText(fileBuffer, encoding));
setFileError("");
} catch (err) {
setCsvText("");
setFileError(err instanceof Error ? err.message : String(err));
}
}, [encoding, fileBuffer, sourceType]);
useEffect(() => {
setMappingManuallyChanged(false);
}, [tableHeaderKey]);
useEffect(() => {
if (!table) {
setMappings([]);
setMappingProfileNotice("");
return;
}
if (mappingManuallyChanged) return;
const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch);
if (automaticMatch) {
setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields));
setValueSeparators(automaticMatch.profile.valueSeparators || ",;|");
setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch));
return;
}
setMappings(defaultColumnMappings(table.headers, existingFields));
setMappingProfileNotice("");
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
useEffect(() => {
if (!hasContent) setActiveStep("upload");
}, [hasContent]);
useEffect(() => {
if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview");
}, [activeStep, fileLinkCapability]);
useEffect(() => {
if (activeStep === "files" && fileLinkCapability) void refreshFileLinks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]);
async function readFile(file: File | undefined) {
if (!file) return;
setFilename(file.name);
setFileError("");
setWorkbookSheets([]);
setSelectedSheetName("");
try {
const buffer = await file.arrayBuffer();
if (isXlsxFile(file)) {
const sheets = await xlsxSheetsFromArrayBuffer(buffer);
if (sheets.length === 0) {
throw new Error("i18n:govoplan-campaign.workbook_contains_no_readable_sheets.02c18603");
}
setSourceType("xlsx");
setFileBuffer(null);
setCsvText("");
setWorkbookSheets(sheets);
setSelectedSheetName(sheets[0]?.name ?? "");
} else {
setSourceType("csv");
setFileBuffer(buffer);
setCsvText(decodeImportText(buffer, encoding));
}
setActiveStep("parse");
} catch (err) {
setFileError(err instanceof Error ? err.message : String(err));
}
}
function canOpenStep(stepId: RecipientImportStepId): boolean {
if (stepId === "upload") return true;
if (stepId === "parse") return hasContent;
if (stepId === "map") return parseReady;
if (stepId === "files" && !fileLinkCapability) return false;
return parseReady && mappings.length > 0;
}
function stepStatus(stepId: RecipientImportStepId): string {
if (stepId === "upload") return filename || (hasContent ? "i18n:govoplan-campaign.pasted_data.5b58080b" : "i18n:govoplan-campaign.waiting.33d30632");
if (stepId === "parse") return table ? `${table.dataRows.length} rows` : "i18n:govoplan-campaign.set_parsing.be7b05fd";
if (stepId === "map") return `${mappedColumnCount} mapped`;
if (stepId === "preview") return preview ? `${preview.validCount} valid` : "i18n:govoplan-campaign.review.e29a79fe";
if (!preview || preview.patternCount === 0) return "i18n:govoplan-campaign.no_patterns.5e1e46fa";
if (fileLinkResolving) return "i18n:govoplan-campaign.checking.97876b83";
if (fileLinkResolution) return i18nMessage("i18n:govoplan-campaign.value_to_link.50cdc659", { value0: fileLinkResolution.linkableFiles.length });
if (fileLinkError) return "i18n:govoplan-campaign.needs_setup.522ebae4";
return `${renderedPatterns.length} patterns`;
}
function goNext() {
if (nextStep && canOpenStep(nextStep)) setActiveStep(nextStep);
}
async function refreshFileLinks() {
setFileLinkNotice("");
if (!fileLinkCapability) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!preview || preview.patternCount === 0) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!defaultAttachmentBasePath?.source) {
setFileLinkResolution(null);
setFileLinkError("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.9c528afa");
return;
}
setFileLinkResolving(true);
setFileLinkError("");
try {
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
} catch (err) {
setFileLinkResolution(null);
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinkResolving(false);
}
}
async function linkImportedFiles() {
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
setFileLinking(true);
setFileLinkError("");
setFileLinkNotice("");
try {
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles);
await refreshFileLinks();
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
} catch (err) {
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinking(false);
}
}
function rememberCurrentMappingProfile() {
if (!table || mappings.length === 0) return;
const tableSnapshot = table;
const mappingsSnapshot = mappings;
const fallbackProfiles = mappingProfiles;
const filenameSnapshot = filename;
const parseOptions = { headerRows, quoted };
const valueSeparatorsSnapshot = valueSeparators;
void (async () => {
try {
const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles);
const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles);
const draftProfile = buildRecipientMappingProfile({
id: reusableProfile?.id,
name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot),
table: tableSnapshot,
mappings: mappingsSnapshot,
parseOptions,
valueSeparators: valueSeparatorsSnapshot,
createdAt: reusableProfile?.createdAt,
updatedAt: new Date().toISOString()
});
const payload = mappingProfilePayload(draftProfile);
await (reusableProfile ?
updateRecipientImportMappingProfile(settings, reusableProfile.id, payload) :
createRecipientImportMappingProfile(settings, payload));
} catch {
// Import acceptance must not be blocked by passive profile learning.
}})();}function confirmRecipientImport(nextPreview: RecipientImportPreview) {rememberCurrentMappingProfile();
const provenance = createRecipientImportProvenance({
preview: nextPreview,
mappings,
mode,
sourceType,
filename,
sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null,
encoding: sourceType === "xlsx" ? null : encoding,
delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter),
headerRows,
quoted: sourceType === "xlsx" ? null : quoted,
valueSeparators
});
onImport(nextPreview, mode, provenance);
}
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
const columnCount = table?.headers.length ?? 0;
setMappingManuallyChanged(true);
setMappings((current) => {
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
byColumn.set(nextMapping.columnIndex, nextMapping);
return Array.from({ length: columnCount }, (_value, columnIndex) => byColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" });
});
}
function changeColumnKind(columnIndex: number, kind: RecipientColumnKind) {
const header = table?.headers[columnIndex] ?? `Column ${columnIndex + 1}`;
const current = mappings.find((mapping) => mapping.columnIndex === columnIndex);
const nextMapping: RecipientColumnMapping = { columnIndex, kind };
if (kind === "field") nextMapping.fieldName = current?.fieldName || existingFields[0]?.name || "";
if (kind === "new_field") nextMapping.newFieldName = current?.newFieldName || suggestImportFieldName(header);
replaceColumnMapping(nextMapping);
}
const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
id: `${columnIndex}-${header}`,
header,
columnIndex,
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
}));
type MappingRow = (typeof mappingRows)[number];
const mappingColumns: DataGridColumn<MappingRow>[] = [
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, maxWidth: 520, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, maxWidth: 460, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
];
type PreviewRow = RecipientImportPreview["rows"][number];
const previewColumns: DataGridColumn<PreviewRow>[] = [
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, maxWidth: 520, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, maxWidth: 600, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
];
const stepContent = activeStep === "upload" ?
<>
<div className="campaign-header-grid recipient-import-upload-grid">
<FormField label="i18n:govoplan-campaign.recipient_file.693007d2">
<FileDropZone
accept=".csv,.tsv,.txt,.xlsx,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
multiple={false}
label="i18n:govoplan-campaign.drop_recipient_file_here.c7c2bddf"
actionLabel="i18n:govoplan-campaign.or_click_to_select_file.0e72f25d"
note={filename || "i18n:govoplan-campaign.csv_tsv_text_or_xlsx.5dcdac76"}
onFiles={(files) => readFile(files[0])} />
</FormField>
<FormField label="i18n:govoplan-campaign.import_mode.7d161dff">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
</select>
</FormField>
</div>
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
{sourceType === "xlsx" ?
<DismissibleAlert tone="info" compact dismissible={false}>
i18n:govoplan-campaign.workbook_loaded.cfaf40eb {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}.
</DismissibleAlert> :
<FormField label={filename ? i18nMessage("i18n:govoplan-campaign.raw_content_value.15e098b8", { value0: filename }) : "i18n:govoplan-campaign.raw_content.c687fb57"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setFileBuffer(null);
setSourceType(filename ? "csv" : "text");
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false} />
</FormField>
}
</> :
activeStep === "parse" ?
<>
<div className="campaign-header-grid">
{sourceType === "xlsx" &&
<FormField label="i18n:govoplan-campaign.sheet.53bc47a7">
<select value={selectedSheet?.name ?? ""} onChange={(event) => setSelectedSheetName(event.target.value)}>
{workbookSheets.map((sheet) =>
<option key={sheet.name} value={sheet.name}>{sheet.name}</option>
)}
</select>
</FormField>
}
<FormField label="i18n:govoplan-campaign.header_rows.9814b9e3">
<input
type="number"
min={0}
max={10}
value={headerRows}
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))} />
</FormField>
{sourceType !== "xlsx" &&
<>
<FormField label="i18n:govoplan-campaign.encoding.5821fec7">
<select value={encoding} onChange={(event) => setEncoding(event.target.value)}>
{RECIPIENT_IMPORT_ENCODINGS.map((option) =>
<option key={option.value} value={option.value}>{option.label}</option>
)}
</select>
</FormField>
<FormField label="i18n:govoplan-campaign.separator.b4b289a7">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">i18n:govoplan-campaign.auto.c614ba7c</option>
<option value=",">i18n:govoplan-campaign.comma.b9ee3dea</option>
<option value=";">i18n:govoplan-campaign.semicolon.727ceca9</option>
<option value={"\t"}>i18n:govoplan-campaign.tab.fe06eb64</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="i18n:govoplan-campaign.quoted_contents.f7fd335a" checked={quoted} onChange={setQuoted} />
</div>
</>
}
</div>
{table &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>{sourceType === "xlsx" ? "i18n:govoplan-campaign.sheet.53bc47a7" : "i18n:govoplan-campaign.separator.b4b289a7"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "i18n:govoplan-campaign.workbook.b4418e62" : formatDelimiter(table.delimiter)}</dd></div>
<div><dt>i18n:govoplan-campaign.header_rows.9814b9e3</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.data_rows.3734724c</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.columns.cf723c59</dt><dd>{table.headers.length}</dd></div>
</dl>
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
</>
}
</> :
activeStep === "map" ?
<>
{mappingProfileError && <DismissibleAlert tone="warning" compact resetKey={mappingProfileError}>{mappingProfileError}</DismissibleAlert>}
{mappingProfileNotice && <DismissibleAlert tone="info" compact resetKey={mappingProfileNotice}>{mappingProfileNotice}</DismissibleAlert>}
<div className="campaign-header-grid recipient-import-map-controls">
<FormField label="i18n:govoplan-campaign.multi_value_separators.6dd66f70">
<input
value={valueSeparators}
onChange={(event) => {
setMappingManuallyChanged(true);
setValueSeparators(event.target.value);
}} />
</FormField>
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append.6b3a6022</option>
<option value="replace">i18n:govoplan-campaign.replace.a7cf7b25</option>
</select>
</FormField>
</div>
<DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
</> :
activeStep === "preview" ?
<>
{preview &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>i18n:govoplan-campaign.rows.52d0b352</dt><dd>{preview.validCount} i18n:govoplan-campaign.valid.210cece7 {preview.invalidCount} invalid</dd></div>
<div><dt>i18n:govoplan-campaign.fields.e8b68527</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{preview.patternCount} from {patternRows} i18n:govoplan-campaign.row_s.61464061</dd></div>
<div><dt>i18n:govoplan-campaign.source.6da13add</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b" : "i18n:govoplan-campaign.no_patterns.5e1e46fa"}</dd></div>
</dl>
{preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
}
<DataGrid
id="campaign-recipient-import-preview"
className="recipient-import-preview-grid"
rows={preview.rows.slice(0, 20)}
columns={previewColumns}
getRowKey={(row) => String(row.rowNumber)}
rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
/>
</>
}
</> :
<RecipientImportFileLinkStep
preview={preview}
basePath={defaultAttachmentBasePath}
resolution={fileLinkResolution}
resolving={fileLinkResolving}
linking={fileLinking}
error={fileLinkError}
notice={fileLinkNotice}
onRefresh={() => void refreshFileLinks()}
onLink={() => void linkImportedFiles()} />;
return (
<Dialog
open
title="i18n:govoplan-campaign.import_recipients.9d6dbe45"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={fileLinking || fileLinkResolving}
closeOnBackdrop={!fileLinking && !fileLinkResolving}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.cancel.77dfd213</Button>
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.back.b52b36b7</Button>}
{!isLastStep ?
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>i18n:govoplan-campaign.next.bc981983</Button> :
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && confirmRecipientImport(preview)}>i18n:govoplan-campaign.import_valid_rows.c3b2642b</Button>
}
</>
}>
<nav className="recipient-import-steps" aria-label="i18n:govoplan-campaign.recipient_import_steps.35dcf028">
<div className="recipient-import-step-track">
{recipientImportSteps.map((step, index) => {
const stepIndex = recipientImportSteps.findIndex((item) => item.id === step.id);
const stepState = stepIndex < activeStepIndex ? "is-complete" : step.id === activeStep ? "is-current" : "";
return (
<div className="recipient-import-step-group" key={step.id}>
<button
type="button"
className={`recipient-import-step-item ${stepState}`.trim()}
disabled={!canOpenStep(step.id)}
aria-current={step.id === activeStep ? "step" : undefined}
onClick={() => setActiveStep(step.id)}>
<span className="recipient-import-step-icon">{index + 1}</span>
<span className="recipient-import-step-copy">
<strong>{step.label}</strong>
<small>{stepStatus(step.id)}</small>
</span>
</button>
{index < recipientImportSteps.length - 1 && <span className="recipient-import-step-line" aria-hidden="true" />}
</div>);
})}
</div>
</nav>
<section className="recipient-import-step-panel">
{stepContent}
</section>
</Dialog>);
}
type RecipientImportFileLinkStepProps = {
preview: RecipientImportPreview | null;
basePath: AttachmentBasePath | null;
resolution: ImportFileLinkResolution | null;
resolving: boolean;
linking: boolean;
error: string;
notice: string;
onRefresh: () => void;
onLink: () => void;
};
function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving, linking, error, notice, onRefresh, onLink }: RecipientImportFileLinkStepProps) {
const linkableIds = new Set(resolution?.linkableFiles.map((file) => file.id) ?? []);
if (!preview || preview.patternCount === 0) {
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
}
type ResolvedFile = ImportFileLinkResolution["files"][number];
const fileColumns: DataGridColumn<ResolvedFile>[] = [
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, maxWidth: 680, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
];
return (
<div className="recipient-import-file-step">
<div className="recipient-import-file-actions">
<div>
<strong>{basePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b"}</strong>
<p className="muted small-note">i18n:govoplan-campaign.files_matched_by_imported_recipient_attachment_p.9b97aca3</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onRefresh} disabled={resolving || linking}>{resolving ? "i18n:govoplan-campaign.checking.820d6004" : "i18n:govoplan-campaign.refresh_matches.11e36411"}</Button>
<Button variant="primary" onClick={onLink} disabled={resolving || linking || !resolution || resolution.linkableFiles.length === 0}>{linking ? "i18n:govoplan-campaign.linking.6f640897" : i18nMessage("i18n:govoplan-campaign.link_value_file_s.ca800d96", { value0: resolution?.linkableFiles.length ?? 0 })}</Button>
</div>
</div>
{error && <DismissibleAlert tone="warning" compact resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" compact resetKey={notice}>{notice}</DismissibleAlert>}
{resolving && <DismissibleAlert tone="info" compact dismissible={false}>i18n:govoplan-campaign.resolving_imported_file_patterns.3aea140f</DismissibleAlert>}
{resolution &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{resolution.patterns.length}</dd></div>
<div><dt>i18n:govoplan-campaign.matched_files.f79c63bb</dt><dd>{resolution.files.length}</dd></div>
<div><dt>i18n:govoplan-campaign.already_linked.7d6c3dd5</dt><dd>{resolution.linkedFiles.length}</dd></div>
<div><dt>i18n:govoplan-campaign.need_linking.a7617722</dt><dd>{resolution.linkableFiles.length}</dd></div>
</dl>
{resolution.unmatchedPatterns.length > 0 &&
<div className="recipient-import-unmatched-patterns">
<strong>i18n:govoplan-campaign.patterns_without_matches.172afdde</strong>
<ul>
{resolution.unmatchedPatterns.slice(0, 8).map((pattern) =>
<li key={pattern.key}>i18n:govoplan-campaign.row.9bf7a8e8 {pattern.rowNumber}: <code>{pattern.renderedPattern}</code></li>
)}
</ul>
{resolution.unmatchedPatterns.length > 8 && <p className="muted small-note">{resolution.unmatchedPatterns.length - 8} i18n:govoplan-campaign.more_unmatched_pattern_s.1dda9807</p>}
</div>
}
<DataGrid
id="campaign-recipient-import-file-links"
rows={resolution.files}
columns={fileColumns}
getRowKey={(file) => file.id}
emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
/>
</>
}
</div>);
}
type RecipientImportRawTableProps = {
tableRows: string[][];
headerRowCount: number;
columnCount: number;
};
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
type RawRow = (typeof rows)[number];
const columns: DataGridColumn<RawRow>[] = [
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
id: `column-${columnIndex + 1}`,
header: String(columnIndex + 1),
width: "minmax(140px, 1fr)",
minWidth: 120,
maxWidth: 360,
resizable: true,
filterable: true,
value: (row) => row.cells[columnIndex] ?? ""
}))
];
return (
<DataGrid
id="campaign-recipient-import-raw"
className="recipient-import-raw-grid"
rows={rows}
columns={columns}
getRowKey={(row) => String(row.rowIndex)}
rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
initialFit="content"
/>);
}
function formatDelimiter(delimiter: "," | ";" | "\t"): string {
if (delimiter === "\t") return "i18n:govoplan-campaign.tab.fe06eb64";
if (delimiter === ";") return "i18n:govoplan-campaign.semicolon.727ceca9";
return "i18n:govoplan-campaign.comma.b9ee3dea";
}
function isXlsxFile(file: File): boolean {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
function decodeImportText(buffer: ArrayBuffer, encoding: string): string {
try {
return new TextDecoder(encoding).decode(buffer).replace(/^\uFEFF/, "");
} catch (err) {
throw new Error(`Could not decode file as ${encoding}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function mappingProfilePayload(profile: RecipientMappingProfile): RecipientImportMappingProfilePayload {
return {
name: profile.name,
columnCount: profile.columnCount,
headers: profile.headers,
normalizedHeaders: profile.normalizedHeaders,
orderedHeaderFingerprint: profile.orderedHeaderFingerprint,
unorderedHeaderFingerprint: profile.unorderedHeaderFingerprint,
delimiter: profile.delimiter,
headerRows: profile.headerRows,
quoted: profile.quoted,
valueSeparators: profile.valueSeparators,
mappings: profile.mappings
};
}
function defaultMappingProfileName(filename: string, table: {headers: string[];}): string {
const trimmedFilename = filename.trim().replace(/\.[^.]+$/, "");
if (trimmedFilename) return trimmedFilename;
const headerLabel = table.headers.slice(0, 3).map((header) => header.trim()).filter(Boolean).join(", ");
return headerLabel ? i18nMessage("i18n:govoplan-campaign.mapping_value", { value0: headerLabel }) : "i18n:govoplan-campaign.recipient_import_mapping.5fe80f6c";
}
function formatMappingProfileMatch(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return "i18n:govoplan-campaign.exact";
if (match.mode === "unordered") return "i18n:govoplan-campaign.same_headers.e523bbe4";
return `${Math.round(match.score * 100)}%`;
}
function isAutomaticMappingProfileMatch(match: RecipientMappingProfileMatch): boolean {
return match.mode === "ordered" || match.mode === "unordered" || match.score >= AUTOMATIC_MAPPING_PROFILE_MIN_SCORE;
}
function findReusableMappingProfile(table: {headers: string[];}, profiles: RecipientMappingProfile[]): RecipientMappingProfile | null {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles.find((profile) => profile.orderedHeaderFingerprint === fingerprints.orderedHeaderFingerprint) ??
profiles.find((profile) => profile.unorderedHeaderFingerprint === fingerprints.unorderedHeaderFingerprint) ??
null;
}
function mappingProfileMatchNotice(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value.f86f53fd", { value0: match.profile.name });
if (match.mode === "unordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value_matched.08910120", { value0: match.profile.name });
return i18nMessage("i18n:govoplan-campaign.using_mapping_derived_from_previous_import_value.008a66a3", { value0: match.profile.name, value1: formatMappingProfileMatch(match) });
}
function sampleColumnValue(rows: string[][], columnIndex: number): string {
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
}
function formatAddressList(addresses: ImportedAddress[]): string {
return addresses.map((address) => address.name ? `${address.name} <${address.email}>` : address.email).join(", ");
}
function formatImportBytes(value?: number | null): string {
if (!value) return "";
if (value < 1024) return i18nMessage("i18n:govoplan-campaign.bytes_b", { value0: value });
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-campaign.bytes_kb", { value0: (value / 1024).toFixed(1) });
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function suggestImportFieldName(value: string): string {
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned || "field";
}
@@ -0,0 +1,221 @@
import { Pencil } from "lucide-react";
import type { ApiSettings } from "../../../types";
import type { CampaignPostboxCatalog } from "../../../api/campaigns";
import {
Button,
DataGridRowActions,
ToggleSwitch,
i18nMessage,
type DataGridColumn
} from "@govoplan/core-webui";
import AttachmentRulesOverlay from "../components/AttachmentRulesOverlay";
import FieldValueInput from "../components/FieldValueInput";
import {
normalizePostboxTargets
} from "../components/PostboxTargetsDialog";
import { buildTemplatePreviewContext } from "../utils/templatePlaceholders";
import {
getIndividualAttachmentBasePaths,
normalizeAttachmentRules,
type AttachmentRule,
type AttachmentZipCollection
} from "../utils/attachments";
import { getDraftFields } from "../utils/fieldDefinitions";
import { asRecord } from "../utils/campaignView";
import {
getEntryAddresses,
hiddenRecipientAddressMatch,
recipientAddressFilterValue,
recipientAddressSummary
} from "./RecipientAddressEditor";
export type RecipientProfileColumnContext = {
settings: ApiSettings;
campaignId: string;
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;
addressFilter: string;
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;
addRecipient: (afterIndex?: number) => void;
moveEntry: (index: number, targetIndex: number) => void;
removeEntry: (index: number) => void;
};
export function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{
id: "number",
header: "#",
width: 72,
sortable: true,
filterType: "integer",
sticky: "start",
render: (_entry, index) =>
<span className="recipient-data-number">
{index + 1}
</span>,
value: (_entry, index) => index + 1
},
{
id: "recipients",
header: "Recipient(s)",
width: "minmax(320px, 1.4fr)",
maxWidth: 640,
resizable: true,
filterable: true,
render: (entry, index) => {
const summary = recipientAddressSummary(entry, translateText);
const hiddenMatch = hiddenRecipientAddressMatch(entry, addressFilter);
const content = (
<>
<span className="recipient-address-editor-main">
<span className={`recipient-data-address ${summary.empty ? "is-empty" : ""}`}>{summary.primary}</span>
{summary.badges.map((badge) =>
<span className="recipient-extra-bubble" key={badge}>{badge}</span>
)}
{hiddenMatch &&
<span className="recipient-hidden-address-match">
Matched in {hiddenMatch.label}: {hiddenMatch.address}
</span>
}
</span>
{!locked && <Pencil aria-hidden="true" />}
</>
);
if (locked) {
return <div className="recipient-address-editor-trigger is-readonly">{content}</div>;
}
return (
<button
type="button"
className="recipient-address-editor-trigger"
onClick={() => openAddressEditor(index)}>
{content}
</button>);
},
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)",
maxWidth: 480,
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",
width: 180,
filterable: true,
render: (entry, index) => {
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<AttachmentRulesOverlay
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
rules={attachments}
settings={settings}
campaignId={campaignId}
disabled={locked}
buttonLabel={`entries: ${attachments.length}`}
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={buildTemplatePreviewContext(draft, entry)}
onChange={(rules) => updateEntryAttachments(index, rules)} />);
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
}] : []),
...fieldDefinitions.filter((field) => field.can_override !== false).map((field): DataGridColumn<Record<string, unknown>> => ({
id: `field-${field.name}`,
header: field.label || field.name,
width: 190,
minWidth: 160,
maxWidth: 360,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
render: (entry, index) => {
const fields = asRecord(entry.fields);
return (
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked}
onChange={(value) => updateEntryField(index, field.name, value)} />);
},
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
})),
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_entry, index) =>
<DataGridRowActions
disabled={locked}
onAddBelow={() => addRecipient(index)}
onRemove={() => removeEntry(index)}
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
addLabel="i18n:govoplan-campaign.add_recipient_below.52fb47d8"
removeLabel="i18n:govoplan-campaign.remove_recipient.d1bc9f53"
moveUpLabel="i18n:govoplan-campaign.move_recipient_up.90c6bccc"
moveDownLabel="i18n:govoplan-campaign.move_recipient_down.ead5466c" />
}];
}
@@ -1,6 +1,11 @@
import { useState, type CSSProperties, type ReactNode } from "react"; import { useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react"; import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
import { InlineHelp, i18nMessage } from "@govoplan/core-webui"; import {
InlineHelp,
StageRail,
i18nMessage,
type StageRailTone
} from "@govoplan/core-webui";
import { humanize } from "../utils/campaignView"; import { humanize } from "../utils/campaignView";
@@ -39,6 +44,18 @@ const stateColors: Record<FlowState, string> = {
pending: "var(--muted)" pending: "var(--muted)"
}; };
const stageRailTones: Record<FlowState, StageRailTone> = {
complete: "success",
warning: "warning",
danger: "danger",
active: "active",
locked: "neutral",
running: "active",
partial: "partial",
stale: "partial",
pending: "neutral"
};
export function WorkflowNavigation({ export function WorkflowNavigation({
stages, stages,
onSelect onSelect
@@ -47,50 +64,29 @@ export function WorkflowNavigation({
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { }) {
return ( return (
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2"> <StageRail
<div className="review-flow-navigation-track"> className="review-flow-navigation"
{stages.map((stage, index) => { ariaLabel="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2"
onSelect={onSelect}
items={stages.map((stage) => {
const Icon = stage.icon; const Icon = stage.icon;
const nextStage = stages[index + 1];
const connectorState = stageConnectorState(stage);
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
const title = i18nMessage(stage.title); const title = i18nMessage(stage.title);
const shortTitle = i18nMessage(stage.shortTitle);
const stateText = i18nMessage(stage.stateLabel); const stateText = i18nMessage(stage.stateLabel);
const style = { return {
"--review-nav-color": stateColors[stage.state], id: stage.id,
"--review-nav-line-color": stateColors[connectorState], label: i18nMessage(stage.shortTitle),
"--review-nav-line-next-color": stateColors[nextConnectorState] statusLabel: ["active", "locked"].includes(stage.state)
} as CSSProperties; ? undefined
const showSecondaryState = !["active", "locked"].includes(stage.state); : stateText,
return ( title: `${title}: ${stateText}`,
<div className="review-flow-navigation-group" key={stage.id} style={style}> icon: <Icon size={17} strokeWidth={1.8} aria-hidden="true" />,
<button tone: stageRailTones[stage.state],
type="button" connectorTone: stageRailTones[stageConnectorState(stage)],
className="review-flow-navigation-item" locked: stage.state === "locked",
data-state={stage.state} lockedLabel: "i18n:govoplan-campaign.locked.a798882f"
onClick={() => onSelect(stage.id)} };
title={`${title}: ${stateText}`}
>
<span className="review-flow-navigation-icon">
<Icon size={17} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="review-flow-navigation-copy">
<strong>
<span>{shortTitle}</span>
{stage.state === "locked" && (
<LockKeyhole size={12} aria-label="i18n:govoplan-campaign.locked.a798882f" />
)}
</strong>
{showSecondaryState && <small>{stateText}</small>}
</span>
</button>
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
</div>
);
})} })}
</div> />
</nav>
); );
} }
@@ -0,0 +1,176 @@
import { Check, Search } from "lucide-react";
import {
StatusBadge,
TableActionGroup,
type DataGridColumn,
type DataGridListOption
} from "@govoplan/core-webui";
import type { MockMailboxMessage } from "../../../api/mail";
import type { CampaignMessagePreviewAttachment } from "../components/MessagePreviewOverlay";
import { asArray, asRecord, stringifyPreview } from "../utils/campaignView";
import { builtMessageKey } from "./builtMessageQuery";
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" },
{ value: "warning", label: "i18n:govoplan-campaign.warning.e9c45563" },
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
{ value: "blocked", label: "i18n:govoplan-campaign.blocked.99613c74" },
{ value: "excluded", label: "i18n:govoplan-campaign.excluded.9804952b" }];
export function builtMessageColumns(
openMessage: (row: Record<string, unknown>) => void,
reviewedKeys: Set<string>)
: DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, 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: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
value: (row) => String(row.validation_status ?? "unknown")
},
{
id: "postboxTargets",
header: "Postbox targets",
width: 145,
align: "right",
sortable: true,
filterType: "integer",
value: (row) => Number(row.postbox_target_count ?? 0),
render: (row) => {
const count = Number(row.postbox_target_count ?? 0);
return count > 0 ? (
<StatusBadge status="info" label={`${count} frozen`} />
) : (
<span className="muted"></span>
);
}
},
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
}
export function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "skipped", label: "i18n:govoplan-campaign.skipped.5a000ad7" },
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" }];
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.status ?? "info") },
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" },
{ id: "smtp", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", width: "minmax(260px, 1fr)", maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.message ?? "—") }];
}
export function sendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
const statuses: DataGridListOption[] = [
{ value: "smtp_accepted", label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603" },
{ value: "already_accepted", label: "i18n:govoplan-campaign.already_accepted.826e68f2" },
{ value: "outcome_unknown", label: "i18n:govoplan-campaign.outcome_unknown.6e929fca" },
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
{ value: "cancelled", label: "i18n:govoplan-campaign.cancelled.a1bf92ef" },
{ value: "not_claimed", label: "i18n:govoplan-campaign.not_claimed.aa712c1b" },
{ value: "dry_run", label: "i18n:govoplan-campaign.dry_run.485a3d15" }];
return [
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: statuses, display: "pill" }, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
{ id: "job", header: "i18n:govoplan-campaign.job.30c8cb83", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? row.version_id ?? "—") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", width: "minmax(320px, 1fr)", maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) }];
}
export function imapAppendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sticky: "start", sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
{ id: "job", header: "i18n:govoplan-campaign.job.30c8cb83", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? "-") },
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 190, sortable: true, filterable: true, value: (row) => String(row.folder ?? "-") },
{ id: "message", header: "i18n:govoplan-campaign.message.68f4145f", width: "minmax(300px, 1fr)", maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) }];
}
export function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
}
export function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "smtp", label: "i18n:govoplan-campaign.smtp.efff9cca" },
{ value: "imap_append", label: "i18n:govoplan-campaign.imap_append.8c0d9e96" }];
return [
{ id: "kind", header: "i18n:govoplan-campaign.kind.e00ac23f", width: 125, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options, display: "pill" }, value: (row) => String(row.kind ?? "mock") },
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => void openMessage(String(row.id ?? "")) }]} /> }];
}
export function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "i18n:govoplan-campaign.from.3f66052a", value: message.from_header || message.envelope_from || "—" },
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: message.cc_header || null },
{ label: "i18n:govoplan-campaign.bcc.8431acad", value: message.bcc_header || null },
{ label: "i18n:govoplan-campaign.kind.e00ac23f", value: message.kind || "—" },
{ label: "i18n:govoplan-campaign.folder.30baa249", value: message.folder || "—" },
{ label: "i18n:govoplan-campaign.message_id.fa0e4fdd", value: message.message_id || "—" },
{ label: "i18n:govoplan-campaign.size.b7152342", value: `${message.size_bytes || 0} bytes` }];
}
export function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,
sizeBytes: attachment.size_bytes ?? undefined
}));
}
export function synchronousSendReason(option: Record<string, unknown>): string {
const configuredMessage = String(option.message ?? "").trim();
if (configuredMessage) return configuredMessage;
switch (String(option.reason ?? "")) {
case "recipient_limit_exceeded":return "i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a";
case "no_eligible_recipient_jobs":return "i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c";
case "version_not_ready":return "i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc";
case "policy_configuration_invalid":return "i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8";
default:return "i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e";
}
}
export function deliveryControlProgressMessage(action: "pause" | "resume" | "cancel"): string {
if (action === "pause") return "i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58";
if (action === "resume") return "i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2";
return "i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47";
}
export function deliveryPolicySourceLabel(source: string): string {
if (source === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
if (source === "deployment") return "i18n:govoplan-campaign.deployment.327a55f8";
if (source === "deployment_default") return "i18n:govoplan-campaign.deployment_default.aa39f7b4";
if (source === "deployment_ceiling") return "i18n:govoplan-campaign.deployment_ceiling.abbec75b";
return "i18n:govoplan-campaign.not_available.d1a17af1";
}
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router";
import type { ApiSettings, WizardStep } from "../../../types"; import type { ApiSettings, WizardStep } from "../../../types";
import { Stepper } from "@govoplan/core-webui"; import { Stepper } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { EmailAddressInput } from "@govoplan/core-webui"; import { EmailAddressInput } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { WysiwygEditor } from "@govoplan/core-webui/wysiwyg";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "@govoplan/core-webui"; import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "@govoplan/core-webui";
import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView"; import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView";
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor"; import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor";
@@ -145,7 +147,16 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
<div className="form-grid"> <div className="form-grid">
<FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField> <FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField> <FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField> <div className="form-field">
<FieldLabel className="form-label">i18n:govoplan-campaign.html_body.77b5ba37</FieldLabel>
<WysiwygEditor
value={getText(template, "html")}
ariaLabel="i18n:govoplan-campaign.html_body.77b5ba37"
minHeight={240}
sourceRows={10}
onChange={(value) => patch(["template", "html"], value)}
/>
</div>
</div> </div>
</div>); </div>);
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react"; import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router";
import type { ApiSettings, CampaignListItem } from "../../types"; import type { ApiSettings, CampaignListItem } from "../../types";
import { import {
cancelCampaign, cancelCampaign,
@@ -502,7 +502,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}); });
const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [ const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [
{ id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name }, { id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", maxWidth: 680, sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status }, { id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
{ {
id: "version", id: "version",
@@ -728,6 +728,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
header: "i18n:govoplan-campaign.last_result.110b888b", header: "i18n:govoplan-campaign.last_result.110b888b",
width: "minmax(220px, 1fr)", width: "minmax(220px, 1fr)",
minWidth: 200, minWidth: 200,
maxWidth: 720,
resizable: true, resizable: true,
render: (job) => { render: (job) => {
const result = durableResultText(job.last_error) ?? deliveryResultSummary(job); const result = durableResultText(job.last_error) ?? deliveryResultSummary(job);
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, RefreshCw } from "lucide-react"; import { Eye, RefreshCw } from "lucide-react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router";
import { import {
Button, Button,
Card, Card,
@@ -111,6 +111,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
id: "campaign", id: "campaign",
header: "i18n:govoplan-campaign.campaign.69390e16", header: "i18n:govoplan-campaign.campaign.69390e16",
width: "minmax(260px, 1fr)", width: "minmax(260px, 1fr)",
maxWidth: 680,
sortable: true, sortable: true,
filterable: true, filterable: true,
sticky: "start", sticky: "start",
@@ -87,7 +87,7 @@ const templateSubnav = (onBack: () => void): ModuleSubnavGroup<TemplateDetailSec
function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] { function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] {
return [ return [
{ id: "template", header: "i18n:govoplan-campaign.template.3ec1ae06", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` }, { id: "template", header: "i18n:govoplan-campaign.template.3ec1ae06", width: "minmax(240px, 1.4fr)", maxWidth: 680, sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` },
{ id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "plain_text", label: "i18n:govoplan-campaign.plain_text_template_type" }, { value: "html_ready", label: "i18n:govoplan-campaign.html_ready_template_type" }] }, value: (template) => template.type }, { id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "plain_text", label: "i18n:govoplan-campaign.plain_text_template_type" }, { value: "html_ready", label: "i18n:govoplan-campaign.html_ready_template_type" }] }, value: (template) => template.type },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") }, { id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt }, { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
@@ -116,7 +116,7 @@ function templateTypeLabel(type: string): string {
function templateFieldColumns(): DataGridColumn<{id: string;field: string;source: string;required: string;}>[] { function templateFieldColumns(): DataGridColumn<{id: string;field: string;source: string;required: string;}>[] {
return [ return [
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field }, { id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", maxWidth: 480, sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source }, { id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }]; { id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }];
+1 -1
View File
@@ -1,5 +1,5 @@
import { createElement, lazy, useCallback } from "react"; import { createElement, lazy, useCallback } from "react";
import { Navigate, useParams } from "react-router-dom"; import { Navigate, useParams } from "react-router";
import { import {
ResourceAccessBoundary, ResourceAccessBoundary,
hasAnyScope, hasAnyScope,
+1 -88
View File
@@ -1499,6 +1499,7 @@
} }
.review-flow-navigation { .review-flow-navigation {
--stage-rail-partial: var(--review-flow-partial);
position: sticky; position: sticky;
top: 85px; top: 85px;
z-index: 35; z-index: 35;
@@ -1512,94 +1513,6 @@
overflow-x: auto; overflow-x: auto;
} }
.review-flow-navigation-track {
display: flex;
align-items: flex-start;
width: max-content;
min-width: max-content;
margin: 0 auto;
}
.review-flow-navigation-group {
display: flex;
align-items: flex-start;
}
.review-flow-navigation-item {
display: grid;
justify-items: center;
gap: 6px;
min-width: 96px;
padding: 3px 5px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: center;
cursor: pointer;
}
.review-flow-navigation-item:hover .review-flow-navigation-icon,
.review-flow-navigation-item:focus-visible .review-flow-navigation-icon {
background: color-mix(in srgb, var(--review-nav-color) 15%, var(--surface));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--review-nav-color) 12%, transparent);
}
.review-flow-navigation-item:focus-visible {
outline: none;
}
.review-flow-navigation-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid var(--review-nav-color);
border-radius: 50%;
color: color-mix(in srgb, var(--review-nav-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-nav-color) 8%, var(--surface));
transition: background .16s ease, box-shadow .16s ease;
}
.review-flow-navigation-copy {
display: grid;
justify-items: center;
min-width: 0;
}
.review-flow-navigation-copy strong {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
color: var(--text-strong);
font-size: 12px;
white-space: nowrap;
}
.review-flow-navigation-copy small {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 14px;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.review-flow-navigation-line {
width: 42px;
height: 2px;
margin: 21px 2px 0;
flex: 0 0 auto;
background: linear-gradient(
to right,
var(--review-nav-line-color, var(--review-nav-color)),
var(--review-nav-line-next-color, var(--review-nav-next-color, var(--review-nav-color)))
);
opacity: .82;
}
.review-flow-timeline { .review-flow-timeline {
width: 100%; width: 100%;
max-width: none; max-width: none;
@@ -6,6 +6,10 @@ const api = readFileSync("src/api/campaigns.ts", "utf8");
const page = readFileSync("src/features/reports/AggregateReportsPage.tsx", "utf8"); const page = readFileSync("src/features/reports/AggregateReportsPage.tsx", "utf8");
const reportPage = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8"); const reportPage = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8");
const reviewPage = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8"); const reviewPage = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
const reviewPresentation = readFileSync(
"src/features/campaigns/review/reviewPresentation.tsx",
"utf8"
);
const operatorPage = readFileSync("src/features/operator/OperatorQueuePage.tsx", "utf8"); const operatorPage = readFileSync("src/features/operator/OperatorQueuePage.tsx", "utf8");
const deliveryMode = readFileSync("src/features/campaigns/utils/deliveryMode.ts", "utf8"); const deliveryMode = readFileSync("src/features/campaigns/utils/deliveryMode.ts", "utf8");
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8"); const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
@@ -50,7 +54,14 @@ assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses
assert(page.includes("all_persisted_recipient_delivery_jobs_for_the_se.208e90cc"), "the denominator explanation is bilingual"); assert(page.includes("all_persisted_recipient_delivery_jobs_for_the_se.208e90cc"), "the denominator explanation is bilingual");
assert(page.includes("positive_counts_below_the_threshold_are_hidden_a.1dc97093"), "the suppression rule is bilingual"); assert(page.includes("positive_counts_below_the_threshold_are_hidden_a.1dc97093"), "the suppression rule is bilingual");
const touchedSources = [page, reportPage, reviewPage, operatorPage, deliveryMode].join("\n"); const touchedSources = [
page,
reportPage,
reviewPage,
reviewPresentation,
operatorPage,
deliveryMode
].join("\n");
const touchedKeys = new Set(touchedSources.match(/i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8}/g) ?? []); const touchedKeys = new Set(touchedSources.match(/i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8}/g) ?? []);
for (const key of touchedKeys) { for (const key of touchedKeys) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1,9 +1,13 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
const source = readFileSync( const source = [
new URL("../src/features/campaigns/RecipientDataPage.tsx", import.meta.url), "../src/features/campaigns/RecipientDataPage.tsx",
"utf8" "../src/features/campaigns/recipients/RecipientAddressEditor.tsx",
).replace(/\s+/g, " "); "../src/features/campaigns/recipients/recipientProfileColumns.tsx"
]
.map((path) => readFileSync(new URL(path, import.meta.url), "utf8"))
.join("\n")
.replace(/\s+/g, " ");
function assertIncludes(fragment, message) { function assertIncludes(fragment, message) {
if (!source.includes(fragment.replace(/\s+/g, " "))) throw new Error(message); if (!source.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);