Refactor campaign recipient and review presentation
This commit is contained in:
@@ -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
@@ -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
@@ -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"
|
||||||
const Icon = stage.icon;
|
onSelect={onSelect}
|
||||||
const nextStage = stages[index + 1];
|
items={stages.map((stage) => {
|
||||||
const connectorState = stageConnectorState(stage);
|
const Icon = stage.icon;
|
||||||
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
|
const title = i18nMessage(stage.title);
|
||||||
const title = i18nMessage(stage.title);
|
const stateText = i18nMessage(stage.stateLabel);
|
||||||
const shortTitle = i18nMessage(stage.shortTitle);
|
return {
|
||||||
const stateText = i18nMessage(stage.stateLabel);
|
id: stage.id,
|
||||||
const style = {
|
label: i18nMessage(stage.shortTitle),
|
||||||
"--review-nav-color": stateColors[stage.state],
|
statusLabel: ["active", "locked"].includes(stage.state)
|
||||||
"--review-nav-line-color": stateColors[connectorState],
|
? undefined
|
||||||
"--review-nav-line-next-color": stateColors[nextConnectorState]
|
: stateText,
|
||||||
} as CSSProperties;
|
title: `${title}: ${stateText}`,
|
||||||
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
icon: <Icon size={17} strokeWidth={1.8} aria-hidden="true" />,
|
||||||
return (
|
tone: stageRailTones[stage.state],
|
||||||
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
connectorTone: stageRailTones[stageConnectorState(stage)],
|
||||||
<button
|
locked: stage.state === "locked",
|
||||||
type="button"
|
lockedLabel: "i18n:govoplan-campaign.locked.a798882f"
|
||||||
className="review-flow-navigation-item"
|
};
|
||||||
data-state={stage.state}
|
})}
|
||||||
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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user