refactor(campaign): split review and recipient boundaries
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user