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