refactor(campaign): split review and recipient boundaries

This commit is contained in:
2026-07-30 01:01:26 +02:00
parent 23b9a531d5
commit 961d5d1130
12 changed files with 1860 additions and 1179 deletions

View File

@@ -63,6 +63,10 @@ import {
type RecipientMappingProfile,
type RecipientMappingProfileMatch } from
"./utils/bulkImport";
import {
buildAddressSourceImportPreview,
createAddressSourceImportProvenance
} from "./utils/addressSourceImport";
import {
bulkLinkFilesToCampaign,
type ImportFileLinkCapability,
@@ -77,6 +81,7 @@ import {
type MailboxAddress } from
"@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
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" },
@@ -85,7 +90,6 @@ const;
type RecipientAddressKey = "to" | "cc" | "bcc";
type AddressFieldKey = RecipientAddressKey | "from" | "reply_to";
type AddressSourceFilter = "all" | "books" | "lists";
type HeaderAddressEditorState = {
title: string;
columns: EntryAddressColumn[];
@@ -941,242 +945,6 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
}
type AddressSourceImportDialogProps = {
settings: ApiSettings;
campaignId: string;
sources: CampaignRecipientAddressSource[];
initialSourceId?: string;
onCancel: () => void;
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
};
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
}
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
if (addressSourceType(source) === "list") return "Address list";
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
return "Address book";
}
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
const scopeType = String(provenance.scope_type ?? "").trim();
const scopeId = String(provenance.scope_id ?? "").trim();
if (!scopeType) return "Unknown scope";
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
}
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
return [
source.source_label,
source.source_kind,
source.source_id,
addressSourceTypeLabel(source),
addressSourceScopeLabel(source),
provenance.address_book_id,
provenance.address_list_id,
provenance.scope_type,
provenance.scope_id,
provenance.tenant_id
].map((value) => String(value ?? "").toLowerCase()).join(" ");
}
function shortSourceRevision(value: string): string {
if (!value) return "no revision";
if (value.length <= 24) return value;
return `${value.slice(0, 19)}...`;
}
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
const navigate = useGuardedNavigate();
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
const [sourceQuery, setSourceQuery] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const sourceCounts = useMemo(() => {
return sources.reduce(
(counts, source) => {
counts[addressSourceType(source)] += 1;
return counts;
},
{ book: 0, list: 0 }
);
}, [sources]);
const filteredSources = useMemo(() => {
const query = sourceQuery.trim().toLowerCase();
return sources.filter((source) => {
const type = addressSourceType(source);
if (sourceFilter === "books" && type !== "book") return false;
if (sourceFilter === "lists" && type !== "list") return false;
if (!query) return true;
return addressSourceSearchText(source).includes(query);
});
}, [sourceFilter, sourceQuery, sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
[selectedSourceId, sources]
);
useEffect(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
}, [filteredSources, selectedSourceId]);
useEffect(() => {
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
setSourceFilter("all");
setSourceQuery("");
setSelectedSourceId(initialSourceId);
}
}, [initialSourceId, sources]);
useEffect(() => {
if (!selectedSourceId) {
setSnapshot(null);
return;
}
let cancelled = false;
setLoading(true);
setError("");
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
then((nextSnapshot) => {
if (!cancelled) setSnapshot(nextSnapshot);
}).
catch((err) => {
if (cancelled) return;
setSnapshot(null);
setError(err instanceof Error ? err.message : String(err));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function openAddressBookModule() {
onCancel();
navigate("/address-book");
}
return (
<Dialog
open
title="Import recipients from addresses"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={loading}
closeOnBackdrop={!loading}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
Import recipients
</Button>
</>
}>
<div className="address-source-import-controls">
<div>
<SegmentedControl
ariaLabel="Address source type"
value={sourceFilter}
onChange={setSourceFilter}
size="content"
width="inline"
disabled={loading || sources.length === 0}
options={[
{ id: "all", label: `All (${sources.length})` },
{ id: "books", label: `Books (${sourceCounts.book})` },
{ id: "lists", label: `Lists (${sourceCounts.list})` }
]}
/>
</div>
<input
type="search"
value={sourceQuery}
disabled={loading || sources.length === 0}
placeholder="Search address sources"
aria-label="Search address sources"
onChange={(event) => setSourceQuery(event.target.value)}
/>
<Button type="button" onClick={openAddressBookModule}>
Manage address books
</Button>
</div>
<div className="campaign-header-grid recipient-import-upload-grid">
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
{filteredSources.map((source) =>
<button
type="button"
key={source.source_id}
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
disabled={loading}
role="radio"
aria-checked={source.source_id === selectedSourceId}
onClick={() => setSelectedSourceId(source.source_id)}>
<span className="address-source-option-main">
<strong>{source.source_label}</strong>
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
</span>
<span className="address-source-option-meta">
<span>{source.recipient_count} recipients</span>
<code>{shortSourceRevision(source.source_revision)}</code>
</span>
</button>
)}
{sources.length > 0 && filteredSources.length === 0 &&
<div className="empty-state compact-empty">No address sources match the current filter.</div>
}
</div>
<FormField label="Import mode">
<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>
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
{snapshot &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl>
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</>
}
</Dialog>);
}
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
];
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
}
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
@@ -1950,141 +1718,6 @@ function formatImportBytes(value?: number | null): string {
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
const tableRows = [
headers,
...snapshot.recipients.map((recipient) => headers.map((header) => {
if (header === "display_name") return recipient.display_name;
if (header === "email") return recipient.email;
return fieldValueToString((recipient.fields ?? {})[header]);
}))];
const table: RecipientImportTable = {
delimiter: ",",
headers,
headerRows: [headers],
rows: tableRows,
dataRows: tableRows.slice(1),
firstDataRowNumber: 2
};
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
const fieldNamesToCreate = new Set<string>();
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
const email = recipient.email.trim();
const name = recipient.display_name.trim();
const fields = stringFieldsFromAddressSource(recipient.fields);
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
const id = uniqueImportId(
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
usedIds
);
const issues: string[] = [];
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
return {
rowNumber: table.firstDataRowNumber + index,
id,
name,
email,
active: true,
addresses: {
from: [],
to: email ? [{ name, email }] : [],
cc: [],
bcc: [],
reply_to: []
},
fields,
patterns: [],
issues
};
});
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: 0
};
}
function createAddressSourceImportProvenance(
snapshot: CampaignRecipientAddressSourceSnapshot,
preview: RecipientImportPreview,
mode: RecipientImportMode)
: RecipientImportProvenance {
const now = new Date().toISOString();
return {
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
imported_at: now,
mode,
source_type: "addresses",
source_id: snapshot.source_id,
source_label: snapshot.source_label,
source_revision: snapshot.source_revision,
source_provenance: snapshot.provenance,
filename: null,
sheet_name: null,
encoding: null,
delimiter: null,
header_rows: 0,
quoted: null,
value_separators: null,
rows_total: preview.rows.length,
valid_rows: preview.validCount,
invalid_rows: preview.invalidCount,
imported_rows: preview.validCount,
field_names_created: preview.fieldNamesToCreate.slice(),
attachment_patterns: 0,
mapping: []
};
}
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
const fields: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value ?? {})) {
const fieldValue = fieldValueToString(rawValue);
if (fieldValue) fields[key] = fieldValue;
}
return fields;
}
function fieldValueToString(value: unknown): string {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value).trim();
}
function idFragmentFromEmail(value: string): string {
return safeImportIdFragment(value.split("@")[0] ?? "");
}
function safeImportIdFragment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
}
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
const base = safeImportIdFragment(preferred) || "recipient";
let candidate = base;
let counter = 2;
while (usedIds.has(candidate)) {
candidate = `${base}-${counter}`;
counter += 1;
}
usedIds.add(candidate);
return candidate;
}
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";

View File

@@ -1,17 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BarChart3,
Check,
ChevronDown,
FlaskConical,
Link2,
LockKeyhole,
PackageCheck,
Search,
Send,
ShieldCheck,
X,
type LucideIcon } from
ShieldCheck } from
"lucide-react";
import type { ApiSettings } from "../../types";
import {
@@ -34,25 +29,22 @@ import {
sendCampaignNow,
updateCampaignReviewState,
validateVersion,
type CampaignAttachmentPreviewFile,
type CampaignAttachmentPreviewResponse,
type CampaignDeliveryOptions,
type CampaignJobsQuery,
type CampaignJobsResponse,
type CampaignSummary,
type CampaignVersionDetail } from
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, 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 { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
import { i18nMessage } from "@govoplan/core-webui";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
@@ -71,53 +63,41 @@ import {
stringifyPreview } from
"./utils/campaignView";
import { deliveryModeLabel } from "./utils/deliveryMode";
import { getBool, getText } from "./utils/draftEditor";
import { attachmentPreviewLinkableFiles, attachmentPreviewMatchedFiles } from "./utils/attachmentPreview";
import { getText } from "./utils/draftEditor";
import { attachmentPreviewLinkableFiles } from "./utils/attachmentPreview";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders";
type FlowState =
"complete" |
"warning" |
"danger" |
"active" |
"locked" |
"running" |
"partial" |
"stale" |
"pending";
type FlowStageDefinition = {
id: string;
title: string;
shortTitle: string;
description: string;
icon: LucideIcon;
state: FlowState;
connectorState?: FlowState;
stateLabel: string;
lockReason?: string;
};
type DeliverabilityPreflightItem = {
label: string;
detail: string;
state: "ready" | "warning" | "blocked" | "info";
};
import AttachmentLinkingPreview from "./review/AttachmentLinkingPreview";
import DeliverabilityPreflight, {
type DeliverabilityPreflightItem
} from "./review/DeliverabilityPreflight";
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
import BuiltMessagePreview from "./review/BuiltMessagePreview";
import {
WorkflowFact,
WorkflowNavigation,
WorkflowStage,
stageConnectorState,
stateLabel,
type FlowStageDefinition,
type FlowState
} from "./review/WorkflowNavigation";
import {
builtMessageKey,
filterAndSortBuiltMessageRows,
findBuiltMessageIndex,
messageNeedsExplicitReview,
reviewQueryEquals,
sameBuiltMessage,
storedMessageReviewState
} from "./review/builtMessageQuery";
import {
countResolvedAttachments,
formatAddressList,
numberFrom
} from "./review/reviewFormatters";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
const stateColors: Record<FlowState, string> = {
complete: "var(--green)",
warning: "var(--amber)",
danger: "var(--red)",
active: "var(--blue)",
locked: "var(--line-dark)",
running: "var(--blue)",
partial: "var(--review-flow-partial)",
stale: "var(--review-flow-partial)",
pending: "var(--muted)"
};
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" },
@@ -1778,409 +1758,6 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
}
function WorkflowNavigation({ stages, onSelect }: {stages: FlowStageDefinition[];onSelect: (id: string) => void;}) {
return (
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
<div className="review-flow-navigation-track">
{stages.map((stage, index) => {
const Icon = stage.icon;
const nextStage = stages[index + 1];
const connectorState = stageConnectorState(stage);
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
const title = i18nMessage(stage.title);
const shortTitle = i18nMessage(stage.shortTitle);
const stateText = i18nMessage(stage.stateLabel);
const style = {
"--review-nav-color": stateColors[stage.state],
"--review-nav-line-color": stateColors[connectorState],
"--review-nav-line-next-color": stateColors[nextConnectorState]
} as CSSProperties;
const showSecondaryState = !["active", "locked"].includes(stage.state);
return (
<div className="review-flow-navigation-group" key={stage.id} style={style}>
<button
type="button"
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>);
}
function stageConnectorState(stage: FlowStageDefinition): FlowState {
return stage.connectorState ?? stage.state;
}
function WorkflowStage({
stage,
nextState,
nextConnectorState,
children
}: {stage: FlowStageDefinition;nextState?: FlowState;nextConnectorState?: FlowState;children: ReactNode;}) {
const Icon = stage.icon;
const locked = stage.state === "locked";
const [collapsed, setCollapsed] = useState(false);
const title = i18nMessage(stage.title);
const description = i18nMessage(stage.description);
const stateText = i18nMessage(stage.stateLabel);
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
const connectorState = stageConnectorState(stage);
const style = {
"--review-stage-color": stateColors[stage.state],
"--review-stage-line-color": stateColors[connectorState],
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
} as CSSProperties;
return (
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
<div className="review-flow-stage-marker" aria-hidden="true">
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
{nextState && <div className="review-flow-stage-line" />}
</div>
<article className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}>
<header className="card-header review-flow-stage-header">
<h2>
<span>{title}</span>
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="i18n:govoplan-campaign.locked.a798882f" />}
{!locked &&
<span
className="review-flow-state-badge"
data-state={stage.state}
aria-label={stateText}
title={stateText}>
{stateText}
</span>
}
<InlineHelp>{description}</InlineHelp>
</h2>
<div className="review-flow-stage-header-actions">
<button
type="button"
className="card-collapse-toggle"
aria-expanded={!collapsed}
aria-label={collapsed ? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title }) : i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })}
title={collapsed ? "i18n:govoplan-campaign.show_content.0528d8d2" : "i18n:govoplan-campaign.show_header_only.24afefca"}
onClick={() => setCollapsed((value) => !value)}>
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button>
</div>
</header>
{!collapsed &&
<>
<div className="card-body review-flow-stage-content">{children}</div>
{locked &&
<div className="review-flow-lock-message">
<span className="review-flow-lock-icon"><LockKeyhole size={20} aria-hidden="true" /></span>
<span>{lockReason}</span>
</div>
}
</>
}
</article>
</section>);
}
function AttachmentLinkingPreview({
preview,
loading,
error,
linking,
disabled,
onRefresh,
onLink
}: {
preview: CampaignAttachmentPreviewResponse | null;
loading: boolean;
error: string;
linking: boolean;
disabled: boolean;
onRefresh: () => void;
onLink: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const matchedFiles = attachmentPreviewMatchedFiles(preview);
const linkableFiles = attachmentPreviewLinkableFiles(preview);
const linkedCount = preview?.linked_file_count ?? matchedFiles.filter((file) => file.linked_to_campaign !== false).length;
const matchedCount = preview?.matched_file_count ?? matchedFiles.length;
const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length;
return (
<>
<div className="review-flow-data-section attachment-linking-preview">
<div className="attachment-linking-header">
<div>
<h3>i18n:govoplan-campaign.attachment_file_links.0be74fd1</h3>
<p className="muted small-note">i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onRefresh} disabled={loading || linking}>{loading ? "i18n:govoplan-campaign.refreshing.505dddc9" : "i18n:govoplan-campaign.refresh.56e3badc"}</Button>
<Button variant="primary" onClick={onLink} disabled={disabled || loading || linking || unlinkedCount === 0}>
{linking ?
"i18n:govoplan-campaign.linking.a5f54e0f" :
i18nMessage(
unlinkedCount === 1 ? "i18n:govoplan-campaign.link_value_file.4d4ce740" : "i18n:govoplan-campaign.link_value_files.88b7e6a7",
{ value0: unlinkedCount }
)}
</Button>
</div>
</div>
<div className="review-flow-fact-grid">
<WorkflowFact
label="i18n:govoplan-campaign.matched.1bf3ec5b"
value={!loading && matchedFiles.length > 0 ?
<button
type="button"
className="review-flow-fact-detail-button"
aria-haspopup="dialog"
aria-expanded={detailsOpen}
aria-label={i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951", { value0: matchedFiles.length })}
onClick={() => setDetailsOpen(true)}>
{matchedCount}
</button> :
loading ? "..." : matchedCount || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.linked.a089f600" value={loading ? "..." : linkedCount || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.need_link.fa4ab530" value={loading ? "..." : unlinkedCount || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.shared_source.7d4a1bf2" value={loading ? "..." : preview?.shared_file_count ?? "—"} />
</div>
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
{!error && unlinkedCount > 0 &&
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998</p>
}
{!error && !loading && matchedFiles.length === 0 &&
<p className="muted small-note">i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c</p>
}
{matchedFiles.length > 0 &&
<AttachmentLinkingFileList files={matchedFiles} compact />
}
</div>
<Dialog
open={detailsOpen}
title={i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30", { value0: matchedFiles.length })}
className="attachment-linking-detail-modal"
bodyClassName="attachment-linking-detail-body"
onClose={() => setDetailsOpen(false)}
footer={<Button variant="primary" onClick={() => setDetailsOpen(false)}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<p className="muted small-note">i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5</p>
<AttachmentLinkingFileList files={matchedFiles} />
</Dialog>
</>);
}
function AttachmentLinkingFileList({ files, compact = false }: {files: CampaignAttachmentPreviewFile[];compact?: boolean;}) {
return (
<ul
className={`attachment-linking-file-list${compact ? " is-compact" : " is-detail"}`}
tabIndex={0}
aria-label={i18nMessage(
files.length === 1 ? "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824" : "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a",
{ value0: files.length }
)}>
{files.map((file, index) => {
const linked = file.linked_to_campaign !== false;
const Icon = linked ? Link2 : X;
return (
<li key={`${file.id || file.display_path || file.filename}:${index}`} data-linked={linked ? "true" : "false"}>
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
<span>
<strong>{file.filename || file.display_path}</strong>
<small>{linked ? "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc" : "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433"}</small>
{file.display_path && file.display_path !== file.filename && <small className="muted">{file.display_path}</small>}
</span>
</li>
);
})}
</ul>);
}
function DeliverabilityPreflight({ items }: {items: DeliverabilityPreflightItem[];}) {
return (
<section className="deliverability-preflight" aria-label="Deliverability preflight">
<div className="deliverability-preflight-header">
<h3>Deliverability preflight</h3>
<span className="muted small-note">Operator checks before the first live send.</span>
</div>
<div className="deliverability-preflight-grid">
{items.map((item) =>
<div key={item.label} className="deliverability-preflight-item" data-state={item.state}>
<div>
<span>{item.label}</span>
<strong>{item.detail}</strong>
</div>
<StatusBadge status={item.state === "blocked" ? "failed" : item.state === "warning" ? "warning" : item.state === "ready" ? "ready" : "info"} label={humanize(item.state)} />
</div>
)}
</div>
</section>);
}
function BuiltMessagePreview({
campaignJson,
entries,
rows,
index,
canStartSingleMessageSend,
singleMessageSendBusy,
onSelect,
onSendSingle,
onClose
}: {campaignJson: Record<string, unknown>;entries: Record<string, unknown>[];rows: Record<string, unknown>[];index: number;canStartSingleMessageSend: boolean;singleMessageSendBusy: boolean;onSelect: (index: number) => void;onSendSingle: (index: number) => void;onClose: () => void;}) {
const row = rows[index] ?? {};
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
const entry = entries[entryIndex] ?? {};
const template = asRecord(campaignJson.template);
const context = buildTemplatePreviewContext(campaignJson, entry);
const ignoreEmptyFields = getBool(asRecord(campaignJson.validation_policy), "ignore_empty_fields", false);
const html = renderTemplatePreviewText(getText(template, "html"), context, ignoreEmptyFields);
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "i18n:govoplan-campaign.no_subject.7b4e8035");
const issues = asArray(row.issues).map(asRecord);
const resolvedRecipients = asRecord(row.resolved_recipients);
const singleSendDisabledReason = singleMessageSendDisabledReason(row, canStartSingleMessageSend);
return (
<CampaignMessagePreviewOverlay
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
subject={subject}
bodyMode={html.trim() ? "html" : "text"}
text={text}
html={html}
recipientLabel={formatAddressList(resolvedRecipients.to) || String(row.recipient_email ?? `Message ${index + 1}`)}
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "i18n:govoplan-campaign.issue.73781a12")).join(" · ")}` : "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"}
metaItems={builtMessageMetaItems(row)}
attachments={builtMessageAttachments(row)}
navigation={{
index,
total: rows.length,
onFirst: () => onSelect(0),
onPrevious: () => onSelect(Math.max(0, index - 1)),
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
onLast: () => onSelect(rows.length - 1)
}}
actions={
<Button
variant="primary"
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Send only this built message"}
onClick={() => onSendSingle(index)}>
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
</Button>
}
onClose={onClose} />);
}
function singleMessageSendDisabledReason(row: Record<string, unknown>, canStartSingleMessageSend: boolean): string {
if (!canStartSingleMessageSend) {
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
}
const jobId = String(row.id ?? "");
if (!jobId) return "This preview row has no delivery job id.";
const buildStatus = String(row.build_status ?? "");
if (buildStatus !== "built") return "This message has not been built.";
const validationStatus = String(row.validation_status ?? "");
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
return `This message is ${humanize(validationStatus)}.`;
}
const sendStatus = String(row.send_status ?? "not_queued");
const queueStatus = String(row.queue_status ?? "draft");
if (["smtp_accepted", "sent"].includes(sendStatus)) return "This message was already accepted by SMTP.";
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) return `This message is in delivery state ${humanize(sendStatus)}.`;
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) return "Use the explicit retry action for failed messages.";
if (sendStatus === "queued" && queueStatus === "queued") return "";
if (["not_queued", "cancelled"].includes(sendStatus) && ["draft", "cancelled"].includes(queueStatus)) return "";
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
}
function DeliveryJobDetailOverlay({ detail, onClose }: {detail: Record<string, unknown>;onClose: () => void;}) {
const job = asRecord(detail.job);
const attempts = asRecord(detail.attempts);
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
const imapAttempts = asArray(attempts.imap).map(asRecord);
const issues = asArray(job.issues).map(asRecord);
return (
<Dialog
open
title="i18n:govoplan-campaign.delivery_job_details.0d9c5b1f"
className="template-preview-modal"
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(job.subject ?? "-")}</dd></div>
<div><dt>i18n:govoplan-campaign.smtp_status.6211cb2b</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
<div><dt>i18n:govoplan-campaign.imap_status.dbc2e430</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
{job.last_error ? <div><dt>i18n:govoplan-campaign.last_error.5e4df866</dt><dd>{String(job.last_error)}</dd></div> : null}
</dl>
{issues.length > 0 && <AttemptList title="i18n:govoplan-campaign.message_issues.9092a7db" rows={issues} />}
<AttemptList title="i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" rows={smtpAttempts} emptyText="i18n:govoplan-campaign.no_smtp_attempts_were_recorded.ff5b4c5d" />
<AttemptList title="i18n:govoplan-campaign.imap_attempts.e815f0c2" rows={imapAttempts} emptyText="i18n:govoplan-campaign.no_imap_append_attempts_were_recorded_if_status_.12279f7e" />
</Dialog>);
}
function AttemptList({ title, rows, emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75" }: {title: string;rows: Record<string, unknown>[];emptyText?: string;}) {
return (
<section className="review-flow-data-section">
<h3>{title}</h3>
{rows.length === 0 ? <p className="muted small-note">{emptyText}</p> :
<dl className="detail-list">
{rows.map((row, index) =>
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
<dt>{String(row.status ?? row.severity ?? row.code ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: index + 1 }))}</dt>
<dd>
<strong>{String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")}</strong>
{row.started_at || row.created_at ? <span className="muted"> · {formatDateTime(String(row.started_at ?? row.created_at))}</span> : null}
{row.finished_at || row.updated_at ? <span className="muted"> {formatDateTime(String(row.finished_at ?? row.updated_at))}</span> : null}
</dd>
</div>
)}
</dl>
}
</section>);
}
function WorkflowFact({ label, value }: {label: string;value: ReactNode;}) {
return (
<div className="review-flow-fact">
<span>{label}</span>
<strong>{value}</strong>
</div>);
}
function builtMessageColumns(
openMessage: (row: Record<string, unknown>) => void,
reviewedKeys: Set<string>)
@@ -2205,144 +1782,6 @@ reviewedKeys: Set<string>)
}
type ReviewFilterType = "text" | "integer" | "list";
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
function filterAndSortBuiltMessageRows(
rows: Record<string, unknown>[],
query: DataGridQueryState,
reviewedKeys: Set<string>)
: Record<string, unknown>[] {
const filters = query.filters ?? {};
const filtered = rows.filter((row, rowIndex) =>
Object.entries(filters).every(([columnId, filterValue]) => {
if (!isBuiltMessageQueryColumn(columnId)) return true;
if (!filterValue.trim()) return true;
return matchesReviewFilter(
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
filterValue,
builtMessageFilterType(columnId)
);
})
);
if (!query.sort) return filtered;
const { columnId, direction } = query.sort;
if (!isBuiltMessageQueryColumn(columnId)) return filtered;
return [...filtered].sort((left, right) => {
const leftIndex = rows.indexOf(left);
const rightIndex = rows.indexOf(right);
const result = compareReviewValues(
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
);
return direction === "desc" ? -result : result;
});
}
function isBuiltMessageQueryColumn(columnId: string): boolean {
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
}
function builtMessageColumnValue(
columnId: string,
row: Record<string, unknown>,
index: number,
reviewedKeys: Set<string>)
: unknown {
switch (columnId) {
case "number":return Number(row.entry_index ?? index + 1);
case "recipient":return formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—");
case "subject":return String(row.subject ?? "—");
case "validation":return String(row.validation_status ?? "unknown");
case "attachments":return Number(row.attachment_count ?? countResolvedAttachments(row.attachments));
case "reviewed":return row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no";
default:return "";
}
}
function builtMessageFilterType(columnId: string): ReviewFilterType {
if (columnId === "number" || columnId === "attachments") return "integer";
if (columnId === "validation" || columnId === "reviewed") return "list";
return "text";
}
function matchesReviewFilter(value: unknown, filterValue: string, filterType: ReviewFilterType): boolean {
if (!filterValue.trim()) return true;
if (filterType === "list") {
const selected = parseReviewListFilter(filterValue);
return selected.includes(stringifyReviewCell(value));
}
if (filterType === "integer") {
const parsed = parseReviewTypedFilter(filterValue);
if (!parsed.value.trim()) return true;
const actual = parseReviewNumber(value);
const expected = Number(parsed.value);
if (!Number.isFinite(actual) || !Number.isFinite(expected)) return false;
return compareReviewByOperator(actual, expected, parsed.operator);
}
return stringifyReviewCell(value).toLowerCase().includes(filterValue.trim().toLowerCase());
}
function parseReviewListFilter(value: string): string[] {
if (value.startsWith("list:")) {
try {
const parsed = JSON.parse(value.slice(5));
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
}
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
function parseReviewTypedFilter(value: string): {operator: ReviewFilterOperator;value: string;} {
if (!value.includes(":")) return { operator: "eq", value };
const [operator, ...parts] = value.split(":");
if (operator === "contains" || operator === "eq" || operator === "gt" || operator === "gte" || operator === "lt" || operator === "lte") {
return { operator, value: parts.join(":") };
}
return { operator: "eq", value };
}
function parseReviewNumber(value: unknown): number {
if (typeof value === "number") return value;
const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", ".");
if (!text.trim()) return NaN;
return Number(text);
}
function compareReviewByOperator(actual: number, expected: number, operator: ReviewFilterOperator): boolean {
if (operator === "gt") return actual > expected;
if (operator === "gte") return actual >= expected;
if (operator === "lt") return actual < expected;
if (operator === "lte") return actual <= expected;
return actual === expected;
}
function compareReviewValues(left: unknown, right: unknown): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, { numeric: true, sensitivity: "base" });
}
function stringifyReviewCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", ");
return "";
}
function reviewQueryEquals(left: DataGridQueryState, right: DataGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const leftFilters = left.filters ?? {};
const rightFilters = right.filters ?? {};
const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]);
for (const key of keys) {
if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false;
}
return true;
}
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
@@ -2411,84 +1850,6 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
}
function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
return [
{ label: "i18n:govoplan-campaign.from.3f66052a", value: formatSingleAddress(recipients.from) || "—" },
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
{ label: "i18n:govoplan-campaign.cc.c5a976de", value: formatAddressList(recipients.cc) || null },
{ label: "i18n:govoplan-campaign.bcc.4c0145a3", value: formatAddressList(recipients.bcc) || null },
{ label: "i18n:govoplan-campaign.validation.dd74d182", value: String(row.validation_status ?? "—") },
{ label: "i18n:govoplan-campaign.mime_size.c8b9d519", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" }];
}
function builtMessageAttachments(row: Record<string, unknown>): CampaignMessagePreviewAttachment[] {
return asArray(row.attachments).flatMap((value, index) => {
const attachment = asRecord(value);
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
if (managedMatches.length > 0) {
return managedMatches.map((match, matchIndex) => ({
filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`),
detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""),
contentType: stringOrUndefined(match.content_type),
sizeBytes: numberOrUndefined(match.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
if (matches.length > 0) {
return matches.map((match) => ({
filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`),
detail: match,
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
return [{
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`),
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""),
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: null,
archiveLabel: null,
protected: false,
protectionNote: null
}];
});
}
function zipProtectionFromBuiltAttachment(attachment: Record<string, unknown>): {protected: boolean;note: string | null;} {
const zipFilename = stringOrUndefined(attachment.zip_filename);
if (!zipFilename) return { protected: false, note: null };
const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim();
const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode))));
if (!protectedArchive) return { protected: false, note: null };
const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field);
const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local");
const scope = rawScope === "global" ? "global" : "local";
const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
return {
protected: true,
note: source ?
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
};
}
function humanizeScope(scope: string): string {
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "i18n:govoplan-campaign.from.3f66052a", value: message.from_header || message.envelope_from || "—" },
@@ -2510,102 +1871,6 @@ function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePre
}));
}
function countResolvedAttachments(value: unknown): number {
const archives = new Set<string>();
let directCount = 0;
for (const item of asArray(value)) {
const attachment = asRecord(item);
const zipFilename = String(attachment.zip_filename ?? "").trim();
const managedCount = asArray(attachment.managed_matches).length;
const matchCount = asArray(attachment.matches).length;
if (zipFilename && (managedCount > 0 || matchCount > 0)) {
archives.add(zipFilename);
continue;
}
if (managedCount > 0) {
directCount += managedCount;
continue;
}
directCount += matchCount;
}
return directCount + archives.size;
}
function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
}
function storedMessageReviewState(version: CampaignVersionDetail | null): {
buildToken: string;
inspectionComplete: boolean;
reviewedMessageKeys: string[];
} {
const build = asRecord(version?.build_summary);
const buildToken = String(build.build_token ?? build.built_at ?? "");
const review = asRecord(asRecord(version?.editor_state).review_send);
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
}
return {
buildToken,
inspectionComplete: review.inspection_complete === true,
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string")
};
}
function builtMessageKey(row: Record<string, unknown>, index: number): string {
return String(row.entry_id ?? row.entry_index ?? index);
}
function findBuiltMessageIndex(rows: Record<string, unknown>[], row: Record<string, unknown>): number {
const id = String(row.id ?? "").trim();
if (id) {
const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id);
if (index >= 0) return index;
}
const reviewKey = String(row.review_key ?? "").trim();
if (reviewKey) {
const index = rows.findIndex((candidate) => String(candidate.review_key ?? "").trim() === reviewKey);
if (index >= 0) return index;
}
const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim();
if (entryKey) {
const index = rows.findIndex((candidate) => String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey);
if (index >= 0) return index;
}
return rows.indexOf(row);
}
function sameBuiltMessage(
left: Record<string, unknown>,
leftIndex: number,
right: Record<string, unknown>,
rightIndex: number)
: boolean {
const leftId = String(left.id ?? "").trim();
const rightId = String(right.id ?? "").trim();
if (leftId && rightId) return leftId === rightId;
const leftReviewKey = String(left.review_key ?? "").trim();
const rightReviewKey = String(right.review_key ?? "").trim();
if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey;
const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim();
const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim();
if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey;
return left === right || leftIndex === rightIndex;
}
function formatAddressList(value: unknown): string {
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
}
function formatSingleAddress(value: unknown): string {
const address = asRecord(value);
const email = String(address.email ?? "").trim();
const name = String(address.name ?? "").trim();
if (name && email) return `${name} <${email}>`;
return email || name;
}
function synchronousSendReason(option: Record<string, unknown>): string {
const configuredMessage = String(option.message ?? "").trim();
if (configuredMessage) return configuredMessage;
@@ -2631,37 +1896,3 @@ function deliveryPolicySourceLabel(source: string): string {
if (source === "deployment_ceiling") return "i18n:govoplan-campaign.deployment_ceiling.abbec75b";
return "i18n:govoplan-campaign.not_available.d1a17af1";
}
function numberFrom(record: Record<string, unknown>, keys: string[]): number {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
}
return 0;
}
function numberOrUndefined(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
return undefined;
}
function stringOrUndefined(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
return value.trim() || undefined;
}
function stateLabel(state: FlowState): string {
switch (state) {
case "complete":return "i18n:govoplan-campaign.passed.271d60f4";
case "warning":return "i18n:govoplan-campaign.warnings.1430f976";
case "danger":return "i18n:govoplan-campaign.blocked.99613c74";
case "active":return "i18n:govoplan-campaign.available.7c62a142";
case "locked":return "i18n:govoplan-campaign.locked.a798882f";
case "running":return "i18n:govoplan-campaign.running.73989d9c";
case "partial":return "i18n:govoplan-campaign.partial.65de2e2a";
case "stale":return "i18n:govoplan-campaign.stale.189cc40c";
default:return humanize(state);
}
}

View File

@@ -0,0 +1,404 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
DataGrid,
Dialog,
DismissibleAlert,
FormField,
SegmentedControl,
useGuardedNavigate,
type DataGridColumn
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import {
snapshotCampaignRecipientAddressSource,
type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot
} from "../../../api/campaigns";
import type { RecipientImportMode } from "../utils/bulkImport";
import { asRecord } from "../utils/campaignView";
import { fieldValueToString } from "../utils/addressSourceImport";
type AddressSourceFilter = "all" | "books" | "lists";
export default function AddressSourceImportDialog({
settings,
campaignId,
sources,
initialSourceId = "",
onCancel,
onImport
}: {
settings: ApiSettings;
campaignId: string;
sources: CampaignRecipientAddressSource[];
initialSourceId?: string;
onCancel: () => void;
onImport: (
snapshot: CampaignRecipientAddressSourceSnapshot,
mode: RecipientImportMode
) => void;
}) {
const navigate = useGuardedNavigate();
const [selectedSourceId, setSelectedSourceId] = useState(
initialSourceId || sources[0]?.source_id || ""
);
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
const [sourceQuery, setSourceQuery] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [snapshot, setSnapshot] =
useState<CampaignRecipientAddressSourceSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const sourceCounts = useMemo(() => {
return sources.reduce(
(counts, source) => {
counts[addressSourceType(source)] += 1;
return counts;
},
{ book: 0, list: 0 }
);
}, [sources]);
const filteredSources = useMemo(() => {
const query = sourceQuery.trim().toLowerCase();
return sources.filter((source) => {
const type = addressSourceType(source);
if (sourceFilter === "books" && type !== "book") return false;
if (sourceFilter === "lists" && type !== "list") return false;
if (!query) return true;
return addressSourceSearchText(source).includes(query);
});
}, [sourceFilter, sourceQuery, sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
[selectedSourceId, sources]
);
useEffect(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) {
return;
}
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
}, [filteredSources, selectedSourceId]);
useEffect(() => {
if (
initialSourceId &&
sources.some((source) => source.source_id === initialSourceId)
) {
setSourceFilter("all");
setSourceQuery("");
setSelectedSourceId(initialSourceId);
}
}, [initialSourceId, sources]);
useEffect(() => {
if (!selectedSourceId) {
setSnapshot(null);
return;
}
let cancelled = false;
setLoading(true);
setError("");
void snapshotCampaignRecipientAddressSource(
settings,
campaignId,
selectedSourceId
)
.then((nextSnapshot) => {
if (!cancelled) setSnapshot(nextSnapshot);
})
.catch((err) => {
if (cancelled) return;
setSnapshot(null);
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [
campaignId,
selectedSourceId,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
function openAddressBookModule() {
onCancel();
navigate("/address-book");
}
return (
<Dialog
open
title="Import recipients from addresses"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={loading}
closeOnBackdrop={!loading}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>
i18n:govoplan-campaign.cancel.77dfd213
</Button>
<Button
variant="primary"
disabled={loading || !snapshot || snapshot.recipients.length === 0}
onClick={() => snapshot && onImport(snapshot, mode)}
>
Import recipients
</Button>
</>
}
>
<div className="address-source-import-controls">
<div>
<SegmentedControl
ariaLabel="Address source type"
value={sourceFilter}
onChange={setSourceFilter}
size="content"
width="inline"
disabled={loading || sources.length === 0}
options={[
{ id: "all", label: `All (${sources.length})` },
{ id: "books", label: `Books (${sourceCounts.book})` },
{ id: "lists", label: `Lists (${sourceCounts.list})` }
]}
/>
</div>
<input
type="search"
value={sourceQuery}
disabled={loading || sources.length === 0}
placeholder="Search address sources"
aria-label="Search address sources"
onChange={(event) => setSourceQuery(event.target.value)}
/>
<Button type="button" onClick={openAddressBookModule}>
Manage address books
</Button>
</div>
<div className="campaign-header-grid recipient-import-upload-grid">
<div
className="address-source-picker"
role="radiogroup"
aria-label="Address source"
>
{filteredSources.map((source) => (
<button
type="button"
key={source.source_id}
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
disabled={loading}
role="radio"
aria-checked={source.source_id === selectedSourceId}
onClick={() => setSelectedSourceId(source.source_id)}
>
<span className="address-source-option-main">
<strong>{source.source_label}</strong>
<span>
{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}
</span>
</span>
<span className="address-source-option-meta">
<span>{source.recipient_count} recipients</span>
<code>{shortSourceRevision(source.source_revision)}</code>
</span>
</button>
))}
{sources.length > 0 && filteredSources.length === 0 && (
<div className="empty-state compact-empty">
No address sources match the current filter.
</div>
)}
</div>
<FormField label="Import mode">
<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>
{error && (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
)}
{loading && (
<DismissibleAlert tone="info" compact dismissible={false}>
Loading address recipients...
</DismissibleAlert>
)}
{!loading && sources.length === 0 && (
<DismissibleAlert tone="info" dismissible={false}>
No address sources are available to this campaign. Create an address book or
address list in the addresses module first.
</DismissibleAlert>
)}
{snapshot && (
<>
<dl className="detail-list recipient-import-summary">
<div>
<dt>Source</dt>
<dd>{snapshot.source_label}</dd>
</div>
<div>
<dt>Type</dt>
<dd>
{selectedSource
? addressSourceTypeLabel(selectedSource)
: "Address source"}
</dd>
</div>
<div>
<dt>Scope</dt>
<dd>
{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}
</dd>
</div>
<div>
<dt>Recipients</dt>
<dd>{snapshot.recipients.length}</dd>
</div>
<div>
<dt>Revision</dt>
<dd className="mono-small">{snapshot.source_revision}</dd>
</div>
</dl>
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
{snapshot.recipients.length > 20 && (
<p className="muted small-note">
{snapshot.recipients.length - 20} more recipients will be imported.
</p>
)}
</>
)}
</Dialog>
);
}
function addressSourceType(
source: CampaignRecipientAddressSource
): "book" | "list" {
return source.source_id.startsWith("addresses:address_list:") ||
source.source_kind === "address_list"
? "list"
: "book";
}
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
if (addressSourceType(source) === "list") return "Address list";
if (source.source_kind && source.source_kind !== "local") {
return `${source.source_kind} address book`;
}
return "Address book";
}
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
const scopeType = String(provenance.scope_type ?? "").trim();
const scopeId = String(provenance.scope_id ?? "").trim();
if (!scopeType) return "Unknown scope";
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
}
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
return [
source.source_label,
source.source_kind,
source.source_id,
addressSourceTypeLabel(source),
addressSourceScopeLabel(source),
provenance.address_book_id,
provenance.address_list_id,
provenance.scope_type,
provenance.scope_id,
provenance.tenant_id
]
.map((value) => String(value ?? "").toLowerCase())
.join(" ");
}
function shortSourceRevision(value: string): string {
if (!value) return "no revision";
if (value.length <= 24) return value;
return `${value.slice(0, 19)}...`;
}
type AddressSourceSnapshotRecipient =
CampaignRecipientAddressSourceSnapshot["recipients"][number];
function AddressSourceRecipientPreviewGrid({
recipients
}: {
recipients: AddressSourceSnapshotRecipient[];
}) {
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
{
id: "row",
header: "#",
width: 64,
value: (_recipient, index) => index + 1,
render: (_recipient, index) => index + 1
},
{
id: "name",
header: "Name",
width: "minmax(180px, 1fr)",
minWidth: 160,
resizable: true,
sortable: true,
filterable: true,
value: (recipient) => recipient.display_name
},
{
id: "email",
header: "Email",
width: "minmax(220px, 1.2fr)",
minWidth: 190,
resizable: true,
sortable: true,
filterable: true,
value: (recipient) => recipient.email
},
{
id: "fields",
header: "Fields",
width: 100,
sortable: true,
value: (recipient) =>
Object.keys(recipient.fields ?? {}).filter((key) =>
fieldValueToString((recipient.fields ?? {})[key])
).length
}
];
return (
<DataGrid
id="campaign-address-source-import-preview"
rows={recipients}
columns={columns}
getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`}
/>
);
}

View File

@@ -0,0 +1,191 @@
import { useState } from "react";
import { Link2, X } from "lucide-react";
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
import type {
CampaignAttachmentPreviewFile,
CampaignAttachmentPreviewResponse
} from "../../../api/campaigns";
import {
attachmentPreviewLinkableFiles,
attachmentPreviewMatchedFiles
} from "../utils/attachmentPreview";
import { WorkflowFact } from "./WorkflowNavigation";
export default function AttachmentLinkingPreview({
preview,
loading,
error,
linking,
disabled,
onRefresh,
onLink
}: {
preview: CampaignAttachmentPreviewResponse | null;
loading: boolean;
error: string;
linking: boolean;
disabled: boolean;
onRefresh: () => void;
onLink: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const matchedFiles = attachmentPreviewMatchedFiles(preview);
const linkableFiles = attachmentPreviewLinkableFiles(preview);
const linkedCount =
preview?.linked_file_count ??
matchedFiles.filter((file) => file.linked_to_campaign !== false).length;
const matchedCount = preview?.matched_file_count ?? matchedFiles.length;
const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length;
return (
<>
<div className="review-flow-data-section attachment-linking-preview">
<div className="attachment-linking-header">
<div>
<h3>i18n:govoplan-campaign.attachment_file_links.0be74fd1</h3>
<p className="muted small-note">
i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af
</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onRefresh} disabled={loading || linking}>
{loading
? "i18n:govoplan-campaign.refreshing.505dddc9"
: "i18n:govoplan-campaign.refresh.56e3badc"}
</Button>
<Button
variant="primary"
onClick={onLink}
disabled={disabled || loading || linking || unlinkedCount === 0}
>
{linking
? "i18n:govoplan-campaign.linking.a5f54e0f"
: i18nMessage(
unlinkedCount === 1
? "i18n:govoplan-campaign.link_value_file.4d4ce740"
: "i18n:govoplan-campaign.link_value_files.88b7e6a7",
{ value0: unlinkedCount }
)}
</Button>
</div>
</div>
<div className="review-flow-fact-grid">
<WorkflowFact
label="i18n:govoplan-campaign.matched.1bf3ec5b"
value={
!loading && matchedFiles.length > 0 ? (
<button
type="button"
className="review-flow-fact-detail-button"
aria-haspopup="dialog"
aria-expanded={detailsOpen}
aria-label={i18nMessage(
"i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951",
{ value0: matchedFiles.length }
)}
onClick={() => setDetailsOpen(true)}
>
{matchedCount}
</button>
) : loading ? (
"..."
) : (
matchedCount || "—"
)
}
/>
<WorkflowFact
label="i18n:govoplan-campaign.linked.a089f600"
value={loading ? "..." : linkedCount || "—"}
/>
<WorkflowFact
label="i18n:govoplan-campaign.need_link.fa4ab530"
value={loading ? "..." : unlinkedCount || "—"}
/>
<WorkflowFact
label="i18n:govoplan-campaign.shared_source.7d4a1bf2"
value={loading ? "..." : preview?.shared_file_count ?? "—"}
/>
</div>
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
{!error && unlinkedCount > 0 && (
<p className="review-flow-inline-note is-stale">
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
</p>
)}
{!error && !loading && matchedFiles.length === 0 && (
<p className="muted small-note">
i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c
</p>
)}
{matchedFiles.length > 0 && <AttachmentLinkingFileList files={matchedFiles} compact />}
</div>
<Dialog
open={detailsOpen}
title={i18nMessage(
"i18n:govoplan-campaign.attachment_file_links_value.ce230e30",
{ value0: matchedFiles.length }
)}
className="attachment-linking-detail-modal"
bodyClassName="attachment-linking-detail-body"
onClose={() => setDetailsOpen(false)}
footer={
<Button variant="primary" onClick={() => setDetailsOpen(false)}>
i18n:govoplan-campaign.close.bbfa773e
</Button>
}
>
<p className="muted small-note">
i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5
</p>
<AttachmentLinkingFileList files={matchedFiles} />
</Dialog>
</>
);
}
function AttachmentLinkingFileList({
files,
compact = false
}: {
files: CampaignAttachmentPreviewFile[];
compact?: boolean;
}) {
return (
<ul
className={`attachment-linking-file-list${compact ? " is-compact" : " is-detail"}`}
tabIndex={0}
aria-label={i18nMessage(
files.length === 1
? "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824"
: "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a",
{ value0: files.length }
)}
>
{files.map((file, index) => {
const linked = file.linked_to_campaign !== false;
const Icon = linked ? Link2 : X;
return (
<li
key={`${file.id || file.display_path || file.filename}:${index}`}
data-linked={linked ? "true" : "false"}
>
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
<span>
<strong>{file.filename || file.display_path}</strong>
<small>
{linked
? "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc"
: "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433"}
</small>
{file.display_path && file.display_path !== file.filename && (
<small className="muted">{file.display_path}</small>
)}
</span>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,312 @@
import { Button, i18nMessage } from "@govoplan/core-webui";
import CampaignMessagePreviewOverlay, {
type CampaignMessagePreviewAttachment
} from "../components/MessagePreviewOverlay";
import { asArray, asRecord, humanize } from "../utils/campaignView";
import { getBool, getText } from "../utils/draftEditor";
import {
buildTemplatePreviewContext,
renderTemplatePreviewText
} from "../utils/templatePlaceholders";
import {
formatAddressList,
formatSingleAddress,
numberFrom,
numberOrUndefined,
stringOrUndefined
} from "./reviewFormatters";
export default function BuiltMessagePreview({
campaignJson,
entries,
rows,
index,
canStartSingleMessageSend,
singleMessageSendBusy,
onSelect,
onSendSingle,
onClose
}: {
campaignJson: Record<string, unknown>;
entries: Record<string, unknown>[];
rows: Record<string, unknown>[];
index: number;
canStartSingleMessageSend: boolean;
singleMessageSendBusy: boolean;
onSelect: (index: number) => void;
onSendSingle: (index: number) => void;
onClose: () => void;
}) {
const row = rows[index] ?? {};
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
const entry = entries[entryIndex] ?? {};
const template = asRecord(campaignJson.template);
const context = buildTemplatePreviewContext(campaignJson, entry);
const ignoreEmptyFields = getBool(
asRecord(campaignJson.validation_policy),
"ignore_empty_fields",
false
);
const html = renderTemplatePreviewText(
getText(template, "html"),
context,
ignoreEmptyFields
);
const text = renderTemplatePreviewText(
getText(template, "text"),
context,
ignoreEmptyFields
);
const subject = String(
row.subject ||
renderTemplatePreviewText(
getText(template, "subject"),
context,
ignoreEmptyFields
) ||
"i18n:govoplan-campaign.no_subject.7b4e8035"
);
const issues = asArray(row.issues).map(asRecord);
const resolvedRecipients = asRecord(row.resolved_recipients);
const singleSendDisabledReason = singleMessageSendDisabledReason(
row,
canStartSingleMessageSend
);
return (
<CampaignMessagePreviewOverlay
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
subject={subject}
bodyMode={html.trim() ? "html" : "text"}
text={text}
html={html}
recipientLabel={
formatAddressList(resolvedRecipients.to) ||
String(row.recipient_email ?? `Message ${index + 1}`)
}
recipientNote={
issues.length > 0
? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues
.map((issue) =>
String(
issue.message ??
issue.code ??
"i18n:govoplan-campaign.issue.73781a12"
)
)
.join(" · ")}`
: "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"
}
metaItems={builtMessageMetaItems(row)}
attachments={builtMessageAttachments(row)}
navigation={{
index,
total: rows.length,
onFirst: () => onSelect(0),
onPrevious: () => onSelect(Math.max(0, index - 1)),
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
onLast: () => onSelect(rows.length - 1)
}}
actions={
<Button
variant="primary"
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
title={singleSendDisabledReason || "Send only this built message"}
onClick={() => onSendSingle(index)}
>
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
</Button>
}
onClose={onClose}
/>
);
}
function singleMessageSendDisabledReason(
row: Record<string, unknown>,
canStartSingleMessageSend: boolean
): string {
if (!canStartSingleMessageSend) {
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
}
const jobId = String(row.id ?? "");
if (!jobId) return "This preview row has no delivery job id.";
const buildStatus = String(row.build_status ?? "");
if (buildStatus !== "built") return "This message has not been built.";
const validationStatus = String(row.validation_status ?? "");
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
return `This message is ${humanize(validationStatus)}.`;
}
const sendStatus = String(row.send_status ?? "not_queued");
const queueStatus = String(row.queue_status ?? "draft");
if (["smtp_accepted", "sent"].includes(sendStatus)) {
return "This message was already accepted by SMTP.";
}
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
return `This message is in delivery state ${humanize(sendStatus)}.`;
}
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) {
return "Use the explicit retry action for failed messages.";
}
if (sendStatus === "queued" && queueStatus === "queued") return "";
if (
["not_queued", "cancelled"].includes(sendStatus) &&
["draft", "cancelled"].includes(queueStatus)
) {
return "";
}
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
}
function builtMessageMetaItems(row: Record<string, unknown>) {
const recipients = asRecord(row.resolved_recipients);
return [
{
label: "i18n:govoplan-campaign.from.3f66052a",
value: formatSingleAddress(recipients.from) || "—"
},
{
label: "i18n:govoplan-campaign.to.ae79ea1e",
value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—")
},
{
label: "i18n:govoplan-campaign.cc.c5a976de",
value: formatAddressList(recipients.cc) || null
},
{
label: "i18n:govoplan-campaign.bcc.4c0145a3",
value: formatAddressList(recipients.bcc) || null
},
{
label: "i18n:govoplan-campaign.validation.dd74d182",
value: String(row.validation_status ?? "—")
},
{
label: "i18n:govoplan-campaign.mime_size.c8b9d519",
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"
}
];
}
function builtMessageAttachments(
row: Record<string, unknown>
): CampaignMessagePreviewAttachment[] {
return asArray(row.attachments).flatMap((value, index) => {
const attachment = asRecord(value);
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
if (managedMatches.length > 0) {
return managedMatches.map((match, matchIndex) => ({
filename: String(
match.filename ??
match.display_path ??
`Attachment ${index + 1}.${matchIndex + 1}`
),
detail: String(
match.display_path ?? match.relative_path ?? attachment.label ?? ""
),
contentType: stringOrUndefined(match.content_type),
sizeBytes: numberOrUndefined(match.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
const matches = asArray(attachment.matches).filter(
(match): match is string => typeof match === "string" && Boolean(match.trim())
);
if (matches.length > 0) {
return matches.map((match) => ({
filename:
match.split(/[\\/]/).pop() ||
String(attachment.label ?? `Attachment ${index + 1}`),
detail: match,
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
return [{
filename: String(
attachment.filename ??
attachment.filename_used ??
attachment.display_path ??
attachment.label ??
`Attachment ${index + 1}`
),
detail: String(
attachment.display_path ??
attachment.source_path ??
attachment.file_filter ??
""
),
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: null,
archiveLabel: null,
protected: false,
protectionNote: null
}];
});
}
function zipProtectionFromBuiltAttachment(
attachment: Record<string, unknown>
): { protected: boolean; note: string | null } {
const zipFilename = stringOrUndefined(attachment.zip_filename);
if (!zipFilename) return { protected: false, note: null };
const legacyMode = String(
attachment.password_mode ?? attachment.zip_password_mode ?? ""
).trim();
const protectedArchive = getBool(
attachment,
"password_enabled",
getBool(
attachment,
"zip_password_protected",
getBool(
attachment,
"zip_protected",
["direct", "field", "template"].includes(legacyMode)
)
)
);
if (!protectedArchive) return { protected: false, note: null };
const field =
stringOrUndefined(attachment.password_field) ??
stringOrUndefined(attachment.zip_password_field);
const rawScope = String(
attachment.password_scope ?? attachment.zip_password_scope ?? "local"
);
const scope = rawScope === "global" ? "global" : "local";
const method =
String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard"
? "i18n:govoplan-campaign.zipcrypto.03bf7fb4"
: "i18n:govoplan-campaign.aes.41f215a6";
const source = field
? i18nMessage("i18n:govoplan-campaign.scope_field_value", {
value0: humanizeScope(scope),
value1: field
})
: "";
return {
protected: true,
note: source
? i18nMessage("i18n:govoplan-campaign.value_encryption_value", {
value0: source,
value1: method
})
: i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
};
}
function humanizeScope(scope: string): string {
return scope === "global"
? "i18n:govoplan-campaign.global.5f1184f7"
: "i18n:govoplan-campaign.local.dc99d54d";
}

View File

@@ -0,0 +1,50 @@
import { StatusBadge } from "@govoplan/core-webui";
import { humanize } from "../utils/campaignView";
export type DeliverabilityPreflightItem = {
label: string;
detail: string;
state: "ready" | "warning" | "blocked" | "info";
};
export default function DeliverabilityPreflight({
items
}: {
items: DeliverabilityPreflightItem[];
}) {
return (
<section className="deliverability-preflight" aria-label="Deliverability preflight">
<div className="deliverability-preflight-header">
<h3>Deliverability preflight</h3>
<span className="muted small-note">Operator checks before the first live send.</span>
</div>
<div className="deliverability-preflight-grid">
{items.map((item) => (
<div
key={item.label}
className="deliverability-preflight-item"
data-state={item.state}
>
<div>
<span>{item.label}</span>
<strong>{item.detail}</strong>
</div>
<StatusBadge
status={
item.state === "blocked"
? "failed"
: item.state === "warning"
? "warning"
: item.state === "ready"
? "ready"
: "info"
}
label={humanize(item.state)}
/>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,136 @@
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
import { asArray, asRecord, formatDateTime, humanize } from "../utils/campaignView";
import { formatAddressList } from "./reviewFormatters";
export default function DeliveryJobDetailOverlay({
detail,
onClose
}: {
detail: Record<string, unknown>;
onClose: () => void;
}) {
const job = asRecord(detail.job);
const attempts = asRecord(detail.attempts);
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
const imapAttempts = asArray(attempts.imap).map(asRecord);
const issues = asArray(job.issues).map(asRecord);
return (
<Dialog
open
title="i18n:govoplan-campaign.delivery_job_details.0d9c5b1f"
className="template-preview-modal"
onClose={onClose}
footer={
<Button variant="primary" onClick={onClose}>
i18n:govoplan-campaign.close.bbfa773e
</Button>
}
>
<dl className="detail-list">
<div>
<dt>i18n:govoplan-campaign.recipient.90343260</dt>
<dd>
{formatAddressList(asRecord(job.resolved_recipients).to) ||
String(job.recipient_email ?? "-")}
</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.subject.8d183dbd</dt>
<dd>{String(job.subject ?? "-")}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.smtp_status.6211cb2b</dt>
<dd>{humanize(String(job.send_status ?? "-"))}</dd>
</div>
<div>
<dt>i18n:govoplan-campaign.imap_status.dbc2e430</dt>
<dd>{humanize(String(job.imap_status ?? "-"))}</dd>
</div>
{job.last_error ? (
<div>
<dt>i18n:govoplan-campaign.last_error.5e4df866</dt>
<dd>{String(job.last_error)}</dd>
</div>
) : null}
</dl>
{issues.length > 0 && (
<AttemptList
title="i18n:govoplan-campaign.message_issues.9092a7db"
rows={issues}
/>
)}
<AttemptList
title="i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
rows={smtpAttempts}
emptyText="i18n:govoplan-campaign.no_smtp_attempts_were_recorded.ff5b4c5d"
/>
<AttemptList
title="i18n:govoplan-campaign.imap_attempts.e815f0c2"
rows={imapAttempts}
emptyText="i18n:govoplan-campaign.no_imap_append_attempts_were_recorded_if_status_.12279f7e"
/>
</Dialog>
);
}
function AttemptList({
title,
rows,
emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75"
}: {
title: string;
rows: Record<string, unknown>[];
emptyText?: string;
}) {
return (
<section className="review-flow-data-section">
<h3>{title}</h3>
{rows.length === 0 ? (
<p className="muted small-note">{emptyText}</p>
) : (
<dl className="detail-list">
{rows.map((row, index) => (
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
<dt>
{String(
row.status ??
row.severity ??
row.code ??
i18nMessage("i18n:govoplan-campaign.value.44b8c76f", {
value0: index + 1
})
)}
</dt>
<dd>
<strong>
{String(
row.message ??
row.error_message ??
row.smtp_response ??
row.folder ??
row.path ??
"-"
)}
</strong>
{row.started_at || row.created_at ? (
<span className="muted">
{" · "}
{formatDateTime(String(row.started_at ?? row.created_at))}
</span>
) : null}
{row.finished_at || row.updated_at ? (
<span className="muted">
{" → "}
{formatDateTime(String(row.finished_at ?? row.updated_at))}
</span>
) : null}
</dd>
</div>
))}
</dl>
)}
</section>
);
}

View File

@@ -0,0 +1,229 @@
import { useState, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
import { humanize } from "../utils/campaignView";
export type FlowState =
| "complete"
| "warning"
| "danger"
| "active"
| "locked"
| "running"
| "partial"
| "stale"
| "pending";
export type FlowStageDefinition = {
id: string;
title: string;
shortTitle: string;
description: string;
icon: LucideIcon;
state: FlowState;
connectorState?: FlowState;
stateLabel: string;
lockReason?: string;
};
const stateColors: Record<FlowState, string> = {
complete: "var(--green)",
warning: "var(--amber)",
danger: "var(--red)",
active: "var(--blue)",
locked: "var(--line-dark)",
running: "var(--blue)",
partial: "var(--review-flow-partial)",
stale: "var(--review-flow-partial)",
pending: "var(--muted)"
};
export function WorkflowNavigation({
stages,
onSelect
}: {
stages: FlowStageDefinition[];
onSelect: (id: string) => void;
}) {
return (
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
<div className="review-flow-navigation-track">
{stages.map((stage, index) => {
const Icon = stage.icon;
const nextStage = stages[index + 1];
const connectorState = stageConnectorState(stage);
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
const title = i18nMessage(stage.title);
const shortTitle = i18nMessage(stage.shortTitle);
const stateText = i18nMessage(stage.stateLabel);
const style = {
"--review-nav-color": stateColors[stage.state],
"--review-nav-line-color": stateColors[connectorState],
"--review-nav-line-next-color": stateColors[nextConnectorState]
} as CSSProperties;
const showSecondaryState = !["active", "locked"].includes(stage.state);
return (
<div className="review-flow-navigation-group" key={stage.id} style={style}>
<button
type="button"
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>
);
}
export function stageConnectorState(stage: FlowStageDefinition): FlowState {
return stage.connectorState ?? stage.state;
}
export function WorkflowStage({
stage,
nextState,
nextConnectorState,
children
}: {
stage: FlowStageDefinition;
nextState?: FlowState;
nextConnectorState?: FlowState;
children: ReactNode;
}) {
const Icon = stage.icon;
const locked = stage.state === "locked";
const [collapsed, setCollapsed] = useState(false);
const title = i18nMessage(stage.title);
const description = i18nMessage(stage.description);
const stateText = i18nMessage(stage.stateLabel);
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
const connectorState = stageConnectorState(stage);
const style = {
"--review-stage-color": stateColors[stage.state],
"--review-stage-line-color": stateColors[connectorState],
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
} as CSSProperties;
return (
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
<div className="review-flow-stage-marker" aria-hidden="true">
<div className="review-flow-stage-node">
<Icon size={20} strokeWidth={1.8} />
</div>
{nextState && <div className="review-flow-stage-line" />}
</div>
<article
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
aria-disabled={locked || undefined}
>
<header className="card-header review-flow-stage-header">
<h2>
<span>{title}</span>
{locked && (
<LockKeyhole
className="review-flow-title-lock"
size={15}
aria-label="i18n:govoplan-campaign.locked.a798882f"
/>
)}
{!locked && (
<span
className="review-flow-state-badge"
data-state={stage.state}
aria-label={stateText}
title={stateText}
>
{stateText}
</span>
)}
<InlineHelp>{description}</InlineHelp>
</h2>
<div className="review-flow-stage-header-actions">
<button
type="button"
className="card-collapse-toggle"
aria-expanded={!collapsed}
aria-label={
collapsed
? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title })
: i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })
}
title={
collapsed
? "i18n:govoplan-campaign.show_content.0528d8d2"
: "i18n:govoplan-campaign.show_header_only.24afefca"
}
onClick={() => setCollapsed((value) => !value)}
>
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button>
</div>
</header>
{!collapsed && (
<>
<div className="card-body review-flow-stage-content">{children}</div>
{locked && (
<div className="review-flow-lock-message">
<span className="review-flow-lock-icon">
<LockKeyhole size={20} aria-hidden="true" />
</span>
<span>{lockReason}</span>
</div>
)}
</>
)}
</article>
</section>
);
}
export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="review-flow-fact">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export function stateLabel(state: FlowState): string {
switch (state) {
case "complete":
return "i18n:govoplan-campaign.passed.271d60f4";
case "warning":
return "i18n:govoplan-campaign.warnings.1430f976";
case "danger":
return "i18n:govoplan-campaign.blocked.99613c74";
case "active":
return "i18n:govoplan-campaign.available.7c62a142";
case "locked":
return "i18n:govoplan-campaign.locked.a798882f";
case "running":
return "i18n:govoplan-campaign.running.73989d9c";
case "partial":
return "i18n:govoplan-campaign.partial.65de2e2a";
case "stale":
return "i18n:govoplan-campaign.stale.189cc40c";
default:
return humanize(state);
}
}

View File

@@ -0,0 +1,258 @@
import type { DataGridQueryState } from "@govoplan/core-webui";
import type { CampaignVersionDetail } from "../../../api/campaigns";
import { asArray, asRecord } from "../utils/campaignView";
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
type ReviewFilterType = "text" | "integer" | "list";
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
export function filterAndSortBuiltMessageRows(
rows: Record<string, unknown>[],
query: DataGridQueryState,
reviewedKeys: Set<string>
): Record<string, unknown>[] {
const filters = query.filters ?? {};
const filtered = rows.filter((row, rowIndex) =>
Object.entries(filters).every(([columnId, filterValue]) => {
if (!isBuiltMessageQueryColumn(columnId)) return true;
if (!filterValue.trim()) return true;
return matchesReviewFilter(
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
filterValue,
builtMessageFilterType(columnId)
);
})
);
if (!query.sort) return filtered;
const { columnId, direction } = query.sort;
if (!isBuiltMessageQueryColumn(columnId)) return filtered;
return [...filtered].sort((left, right) => {
const leftIndex = rows.indexOf(left);
const rightIndex = rows.indexOf(right);
const result = compareReviewValues(
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
);
return direction === "desc" ? -result : result;
});
}
export function reviewQueryEquals(
left: DataGridQueryState,
right: DataGridQueryState
): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const leftFilters = left.filters ?? {};
const rightFilters = right.filters ?? {};
const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]);
for (const key of keys) {
if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false;
}
return true;
}
export function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
}
export function storedMessageReviewState(version: CampaignVersionDetail | null): {
buildToken: string;
inspectionComplete: boolean;
reviewedMessageKeys: string[];
} {
const build = asRecord(version?.build_summary);
const buildToken = String(build.build_token ?? build.built_at ?? "");
const review = asRecord(asRecord(version?.editor_state).review_send);
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
}
return {
buildToken,
inspectionComplete: review.inspection_complete === true,
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter(
(value): value is string => typeof value === "string"
)
};
}
export function builtMessageKey(row: Record<string, unknown>, index: number): string {
return String(row.entry_id ?? row.entry_index ?? index);
}
export function findBuiltMessageIndex(
rows: Record<string, unknown>[],
row: Record<string, unknown>
): number {
const id = String(row.id ?? "").trim();
if (id) {
const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id);
if (index >= 0) return index;
}
const reviewKey = String(row.review_key ?? "").trim();
if (reviewKey) {
const index = rows.findIndex(
(candidate) => String(candidate.review_key ?? "").trim() === reviewKey
);
if (index >= 0) return index;
}
const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim();
if (entryKey) {
const index = rows.findIndex(
(candidate) =>
String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey
);
if (index >= 0) return index;
}
return rows.indexOf(row);
}
export function sameBuiltMessage(
left: Record<string, unknown>,
leftIndex: number,
right: Record<string, unknown>,
rightIndex: number
): boolean {
const leftId = String(left.id ?? "").trim();
const rightId = String(right.id ?? "").trim();
if (leftId && rightId) return leftId === rightId;
const leftReviewKey = String(left.review_key ?? "").trim();
const rightReviewKey = String(right.review_key ?? "").trim();
if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey;
const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim();
const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim();
if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey;
return left === right || leftIndex === rightIndex;
}
function isBuiltMessageQueryColumn(columnId: string): boolean {
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
}
function builtMessageColumnValue(
columnId: string,
row: Record<string, unknown>,
index: number,
reviewedKeys: Set<string>
): unknown {
switch (columnId) {
case "number":
return Number(row.entry_index ?? index + 1);
case "recipient":
return (
formatAddressList(asRecord(row.resolved_recipients).to) ||
String(row.recipient_email ?? "—")
);
case "subject":
return String(row.subject ?? "—");
case "validation":
return String(row.validation_status ?? "unknown");
case "attachments":
return Number(row.attachment_count ?? countResolvedAttachments(row.attachments));
case "reviewed":
return row.reviewed === true ||
reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index)))
? "yes"
: "no";
default:
return "";
}
}
function builtMessageFilterType(columnId: string): ReviewFilterType {
if (["number", "attachments"].includes(columnId)) return "integer";
if (["validation", "reviewed"].includes(columnId)) return "list";
return "text";
}
function matchesReviewFilter(
value: unknown,
filterValue: string,
filterType: ReviewFilterType
): boolean {
if (!filterValue.trim()) return true;
if (filterType === "list") {
const selected = parseReviewListFilter(filterValue);
return selected.includes(stringifyReviewCell(value));
}
if (filterType === "integer") {
const parsed = parseReviewTypedFilter(filterValue);
if (!parsed.value.trim()) return true;
const actual = parseReviewNumber(value);
const expected = Number(parsed.value);
if (!Number.isFinite(expected) || !Number.isFinite(actual)) return false;
return compareReviewByOperator(actual, expected, parsed.operator);
}
return stringifyReviewCell(value)
.toLowerCase()
.includes(filterValue.trim().toLowerCase());
}
function parseReviewListFilter(value: string): string[] {
if (value.startsWith("list:")) {
try {
const parsed = JSON.parse(value.slice(5));
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
function parseReviewTypedFilter(
value: string
): { operator: ReviewFilterOperator; value: string } {
if (!value.includes(":")) return { operator: "eq", value };
const [operator, ...parts] = value.split(":");
if (
operator === "contains" ||
operator === "eq" ||
operator === "gt" ||
operator === "gte" ||
operator === "lt" ||
operator === "lte"
) {
return { operator, value: parts.join(":") };
}
return { operator: "eq", value };
}
function parseReviewNumber(value: unknown): number {
if (typeof value === "number") return value;
const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", ".");
if (!text.trim()) return Number.NaN;
return Number(text);
}
function compareReviewByOperator(
actual: number,
expected: number,
operator: ReviewFilterOperator
): boolean {
if (operator === "gt") return actual > expected;
if (operator === "gte") return actual >= expected;
if (operator === "lt") return actual < expected;
if (operator === "lte") return actual <= expected;
return actual === expected;
}
function compareReviewValues(left: unknown, right: unknown): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, {
numeric: true,
sensitivity: "base"
});
}
function stringifyReviewCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", ");
return "";
}

View File

@@ -0,0 +1,66 @@
import { asArray, asRecord } from "../utils/campaignView";
export function formatAddressList(value: unknown): string {
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
}
export function formatSingleAddress(value: unknown): string {
const address = asRecord(value);
const email = String(address.email ?? "").trim();
const name = String(address.name ?? "").trim();
if (name && email) return `${name} <${email}>`;
return email || name;
}
export function countResolvedAttachments(value: unknown): number {
const archives = new Set<string>();
let directCount = 0;
for (const item of asArray(value)) {
const attachment = asRecord(item);
const zipFilename = String(attachment.zip_filename ?? "").trim();
const managedCount = asArray(attachment.managed_matches).length;
const matchCount = asArray(attachment.matches).length;
if (zipFilename && (managedCount > 0 || matchCount > 0)) {
archives.add(zipFilename);
continue;
}
if (managedCount > 0) {
directCount += managedCount;
continue;
}
directCount += matchCount;
}
return directCount + archives.size;
}
export function numberFrom(record: Record<string, unknown>, keys: string[]): number {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
if (
typeof value === "string" &&
value.trim() &&
Number.isFinite(Number(value))
) {
return Number(value);
}
}
return 0;
}
export function numberOrUndefined(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (
typeof value === "string" &&
value.trim() &&
Number.isFinite(Number(value))
) {
return Number(value);
}
return undefined;
}
export function stringOrUndefined(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
return value.trim() || undefined;
}

View File

@@ -0,0 +1,168 @@
import type { CampaignRecipientAddressSourceSnapshot } from "../../../api/campaigns";
import type {
ImportedRecipientRow,
RecipientImportMode,
RecipientImportPreview,
RecipientImportProvenance,
RecipientImportTable
} from "./bulkImport";
export function buildAddressSourceImportPreview(
snapshot: CampaignRecipientAddressSourceSnapshot,
existingEntries: Record<string, unknown>[]
): RecipientImportPreview {
const headers = [
"display_name",
"email",
"given_name",
"family_name",
"organization",
"role_title",
"phone",
"tags"
];
const tableRows = [
headers,
...snapshot.recipients.map((recipient) =>
headers.map((header) => {
if (header === "display_name") return recipient.display_name;
if (header === "email") return recipient.email;
return fieldValueToString((recipient.fields ?? {})[header]);
})
)
];
const table: RecipientImportTable = {
delimiter: ",",
headers,
headerRows: [headers],
rows: tableRows,
dataRows: tableRows.slice(1),
firstDataRowNumber: 2
};
const usedIds = new Set(
existingEntries.map((entry) => String(entry.id || "")).filter(Boolean)
);
const fieldNamesToCreate = new Set<string>();
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
const email = recipient.email.trim();
const name = recipient.display_name.trim();
const fields = stringFieldsFromAddressSource(recipient.fields);
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
const id = uniqueImportId(
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
usedIds
);
const issues: string[] = [];
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
return {
rowNumber: table.firstDataRowNumber + index,
id,
name,
email,
active: true,
addresses: {
from: [],
to: email ? [{ name, email }] : [],
cc: [],
bcc: [],
reply_to: []
},
fields,
patterns: [],
issues
};
});
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: 0
};
}
export function createAddressSourceImportProvenance(
snapshot: CampaignRecipientAddressSourceSnapshot,
preview: RecipientImportPreview,
mode: RecipientImportMode
): RecipientImportProvenance {
const now = new Date().toISOString();
return {
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
imported_at: now,
mode,
source_type: "addresses",
source_id: snapshot.source_id,
source_label: snapshot.source_label,
source_revision: snapshot.source_revision,
source_provenance: snapshot.provenance,
filename: null,
sheet_name: null,
encoding: null,
delimiter: null,
header_rows: 0,
quoted: null,
value_separators: null,
rows_total: preview.rows.length,
valid_rows: preview.validCount,
invalid_rows: preview.invalidCount,
imported_rows: preview.validCount,
field_names_created: preview.fieldNamesToCreate.slice(),
attachment_patterns: 0,
mapping: []
};
}
export function fieldValueToString(value: unknown): string {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) {
return value.map(fieldValueToString).filter(Boolean).join(", ");
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value).trim();
}
function stringFieldsFromAddressSource(
value: Record<string, unknown> | undefined
): Record<string, string> {
const fields: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value ?? {})) {
const fieldValue = fieldValueToString(rawValue);
if (fieldValue) fields[key] = fieldValue;
}
return fields;
}
function idFragmentFromEmail(value: string): string {
return safeImportIdFragment(value.split("@")[0] ?? "");
}
function safeImportIdFragment(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "") || "source"
);
}
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
const base = safeImportIdFragment(preferred) || "recipient";
let candidate = base;
let counter = 2;
while (usedIds.has(candidate)) {
candidate = `${base}-${counter}`;
counter += 1;
}
usedIds.add(candidate);
return candidate;
}

View File

@@ -57,13 +57,16 @@ const explicitLinkablePreview = {
};
assert(attachmentPreviewLinkableFiles(explicitLinkablePreview).length === 2, "explicit linkable candidates are deduplicated without a display cap");
const reviewSource = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
const reviewSource = [
readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8"),
readFileSync("src/features/campaigns/review/AttachmentLinkingPreview.tsx", "utf8")
].join("\n");
assert(!reviewSource.includes("attachmentPreviewMatchedFiles(preview).slice("), "review attachment links must not use an arbitrary display slice");
assert(reviewSource.includes("<AttachmentLinkingFileList files={matchedFiles} compact />"), "compact review exposes the complete bounded attachment list");
assert(reviewSource.includes('aria-haspopup="dialog"'), "the matched count advertises its attachment detail dialog");
assert(reviewSource.includes("tabIndex={0}"), "bounded attachment lists are keyboard-focusable scroll regions");
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30"'), "dynamic attachment dialog title uses the i18n message contract");
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951"'), "dynamic attachment count aria label uses the i18n message contract");
assert(/i18nMessage\(\s*"i18n:govoplan-campaign\.attachment_file_links_value\.ce230e30"/.test(reviewSource), "dynamic attachment dialog title uses the i18n message contract");
assert(/i18nMessage\(\s*"i18n:govoplan-campaign\.view_all_value_matched_attachment_file_links\.81e53951"/.test(reviewSource), "dynamic attachment count aria label uses the i18n message contract");
assert(!reviewSource.includes(">Attachment file links<"), "attachment review heading is not raw English");
assert(!reviewSource.includes('label="Matched"'), "attachment review facts are not raw English");
assert(!reviewSource.includes("`Link ${unlinkedCount}"), "attachment link actions are not assembled from raw English");