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