Workflow UI redesign - first draft
This commit is contained in:
@@ -9,7 +9,6 @@ import RecipientDetailsPage from "./RecipientDetailsPage";
|
|||||||
import TemplateDataPage from "./TemplateDataPage";
|
import TemplateDataPage from "./TemplateDataPage";
|
||||||
import AttachmentsDataPage from "./AttachmentsDataPage";
|
import AttachmentsDataPage from "./AttachmentsDataPage";
|
||||||
import MailSettingsPage from "./MailSettingsPage";
|
import MailSettingsPage from "./MailSettingsPage";
|
||||||
import ReviewDataPage from "./ReviewDataPage";
|
|
||||||
import SendDataPage from "./SendDataPage";
|
import SendDataPage from "./SendDataPage";
|
||||||
import CreateWizard from "./wizard/CreateWizard";
|
import CreateWizard from "./wizard/CreateWizard";
|
||||||
import ReviewWizard from "./wizard/ReviewWizard";
|
import ReviewWizard from "./wizard/ReviewWizard";
|
||||||
@@ -74,7 +73,6 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
|
|||||||
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
|
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
|
||||||
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="review" element={<ReviewDataPage settings={settings} campaignId={campaignId || ""} />} />
|
|
||||||
<Route path="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import PageTitle from "../../components/PageTitle";
|
|||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
|
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||||
import { clearMockMailboxMessages, getMockMailboxMessage, listImapFolders, listMockMailboxMessages, testImapSettings, testSmtpSettings, updateMockMailboxFailures, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity, type MockMailboxMessage } from "../../api/mail";
|
import { clearMockMailboxMessages, getMockMailboxMessage, listImapFolders, listMockMailboxMessages, testImapSettings, testSmtpSettings, updateMockMailboxFailures, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity, type MockMailboxMessage } from "../../api/mail";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -358,22 +359,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{selectedMockMessage && (
|
|
||||||
<div className="mock-message-detail">
|
|
||||||
<div className="subsection-heading split">
|
|
||||||
<h3>{selectedMockMessage.subject || "Mock message"}</h3>
|
|
||||||
<Button onClick={() => setSelectedMockMessage(null)}>Close details</Button>
|
|
||||||
</div>
|
|
||||||
<div className="detail-grid">
|
|
||||||
<div><span className="muted small-note">From</span><strong>{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}</strong></div>
|
|
||||||
<div><span className="muted small-note">To</span><strong>{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}</strong></div>
|
|
||||||
<div><span className="muted small-note">Size</span><strong>{selectedMockMessage.size_bytes || 0} bytes</strong></div>
|
|
||||||
<div><span className="muted small-note">Folder</span><strong>{selectedMockMessage.folder || "—"}</strong></div>
|
|
||||||
</div>
|
|
||||||
{selectedMockMessage.body_preview && <pre className="mock-message-preview">{selectedMockMessage.body_preview}</pre>}
|
|
||||||
{selectedMockMessage.raw_eml && <details><summary>Raw MIME</summary><pre className="mock-message-raw">{selectedMockMessage.raw_eml}</pre></details>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="button-row page-bottom-actions">
|
<div className="button-row page-bottom-actions">
|
||||||
@@ -381,11 +367,46 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
{selectedMockMessage && (
|
||||||
|
<MessagePreviewOverlay
|
||||||
|
title="Captured mock mail"
|
||||||
|
subject={selectedMockMessage.subject || "Mock message"}
|
||||||
|
bodyMode="text"
|
||||||
|
text={selectedMockMessage.body_preview || ""}
|
||||||
|
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
|
||||||
|
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
|
||||||
|
metaItems={mockMessageMetaItems(selectedMockMessage)}
|
||||||
|
attachments={mockMessageAttachments(selectedMockMessage)}
|
||||||
|
raw={selectedMockMessage.raw_eml}
|
||||||
|
rawLabel="Raw MIME"
|
||||||
|
onClose={() => setSelectedMockMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function mockMessageMetaItems(message: MockMailboxMessage) {
|
||||||
|
return [
|
||||||
|
{ label: "From", value: message.from_header || message.envelope_from || "—" },
|
||||||
|
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
|
||||||
|
{ label: "Kind", value: message.kind || "—" },
|
||||||
|
{ label: "Folder", value: message.folder || "—" },
|
||||||
|
{ label: "Message-ID", value: message.message_id || "—" },
|
||||||
|
{ label: "Size", value: `${message.size_bytes || 0} bytes` }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
|
||||||
|
return (message.attachments ?? []).map((attachment, index) => ({
|
||||||
|
filename: attachment.filename || `Attachment ${index + 1}`,
|
||||||
|
contentType: attachment.content_type || undefined,
|
||||||
|
sizeBytes: attachment.size_bytes ?? undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function formatMockDate(value: string): string {
|
function formatMockDate(value: string): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|||||||
@@ -1,426 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import type { ApiSettings } from "../../types";
|
|
||||||
import Button from "../../components/Button";
|
|
||||||
import PageTitle from "../../components/PageTitle";
|
|
||||||
import LoadingFrame from "../../components/LoadingFrame";
|
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
|
||||||
import VersionLine from "./components/VersionLine";
|
|
||||||
import Card from "../../components/Card";
|
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
|
||||||
import { buildVersion, validateVersion } from "../../api/campaigns";
|
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
||||||
import {
|
|
||||||
asArray,
|
|
||||||
asRecord,
|
|
||||||
formatDateTime,
|
|
||||||
isAuditLockedVersion,
|
|
||||||
isFinalLockedVersion,
|
|
||||||
isUserLockedVersion,
|
|
||||||
isVersionReadyForDelivery,
|
|
||||||
stringifyPreview,
|
|
||||||
summaryValue,
|
|
||||||
versionLockReason,
|
|
||||||
} from "./utils/campaignView";
|
|
||||||
|
|
||||||
export default function ReviewDataPage({
|
|
||||||
settings,
|
|
||||||
campaignId,
|
|
||||||
}: {
|
|
||||||
settings: ApiSettings;
|
|
||||||
campaignId: string;
|
|
||||||
}) {
|
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(
|
|
||||||
settings,
|
|
||||||
campaignId,
|
|
||||||
{ includeSummary: true },
|
|
||||||
);
|
|
||||||
const version = data.currentVersion;
|
|
||||||
const locked = isAuditLockedVersion(version);
|
|
||||||
const auditSafe =
|
|
||||||
isUserLockedVersion(version) || isFinalLockedVersion(version);
|
|
||||||
const validationSummary = asRecord(version?.validation_summary);
|
|
||||||
const buildSummary = asRecord(version?.build_summary);
|
|
||||||
const validationOk = validationSummary.ok === true;
|
|
||||||
const readyForDelivery = isVersionReadyForDelivery(version);
|
|
||||||
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "">("");
|
|
||||||
const [actionMessage, setActionMessage] = useState("");
|
|
||||||
const [lastValidationResult, setLastValidationResult] = useState<Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLastValidationResult(null);
|
|
||||||
}, [version?.id]);
|
|
||||||
|
|
||||||
const issues = useMemo(
|
|
||||||
() =>
|
|
||||||
collectIssues(
|
|
||||||
lastValidationResult,
|
|
||||||
validationSummary,
|
|
||||||
data.summary?.issues,
|
|
||||||
),
|
|
||||||
[lastValidationResult, validationSummary, data.summary?.issues],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function runValidate() {
|
|
||||||
if (!version || actionBusy) return;
|
|
||||||
setActionBusy("validate");
|
|
||||||
setActionMessage("Validating campaign and locking the version on success…");
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const result = await validateVersion(settings, version.id, false);
|
|
||||||
setLastValidationResult(result);
|
|
||||||
setActionMessage(
|
|
||||||
result.ok
|
|
||||||
? "Validation passed. This version is now locked but can still be unlocked before sending."
|
|
||||||
: "Validation finished with issues. See the validation issues below, fix the campaign, and validate again.",
|
|
||||||
);
|
|
||||||
await reload();
|
|
||||||
} catch (err) {
|
|
||||||
setActionMessage("");
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setActionBusy("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBuild() {
|
|
||||||
if (!version || actionBusy) return;
|
|
||||||
setActionBusy("build");
|
|
||||||
setActionMessage("Building the queue for the locked, validated version…");
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const result = await buildVersion(settings, version.id, true);
|
|
||||||
setActionMessage(
|
|
||||||
`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`,
|
|
||||||
);
|
|
||||||
await reload();
|
|
||||||
} catch (err) {
|
|
||||||
setActionMessage("");
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
setActionBusy("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-pad workspace-data-page">
|
|
||||||
<div className="page-heading split workspace-heading">
|
|
||||||
<div>
|
|
||||||
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
|
|
||||||
<VersionLine
|
|
||||||
version={version}
|
|
||||||
versions={data.versions}
|
|
||||||
loadedAt={version?.updated_at}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="button-row compact-actions">
|
|
||||||
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>
|
|
||||||
Reload
|
|
||||||
</Button>
|
|
||||||
<Link to="../wizard/review">
|
|
||||||
<Button variant="primary">Open Review Wizard</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="alert danger">{error}</div>}
|
|
||||||
{actionMessage && <div className="alert info">{actionMessage}</div>}
|
|
||||||
{locked && (
|
|
||||||
<LockedVersionNotice
|
|
||||||
settings={settings}
|
|
||||||
campaignId={campaignId}
|
|
||||||
version={version}
|
|
||||||
reload={reload}
|
|
||||||
message={
|
|
||||||
auditSafe
|
|
||||||
? "Audit-safe snapshot. Create a copy to continue."
|
|
||||||
: "Validated snapshot. Unlock before sending, or copy to edit."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="Loading review data…">
|
|
||||||
<Card
|
|
||||||
title="Review actions"
|
|
||||||
actions={
|
|
||||||
<span className="muted small-note">
|
|
||||||
Validation locks this version; unlocking invalidates validation
|
|
||||||
before sending.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="button-row compact-actions">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={runValidate}
|
|
||||||
disabled={
|
|
||||||
!version ||
|
|
||||||
loading ||
|
|
||||||
Boolean(actionBusy) ||
|
|
||||||
readyForDelivery ||
|
|
||||||
auditSafe
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{actionBusy === "validate"
|
|
||||||
? "Validating…"
|
|
||||||
: readyForDelivery
|
|
||||||
? "Validated and locked"
|
|
||||||
: validationOk
|
|
||||||
? "Validate again"
|
|
||||||
: "Validate and lock"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={runBuild}
|
|
||||||
disabled={
|
|
||||||
!version || loading || Boolean(actionBusy) || !readyForDelivery
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{actionBusy === "build" ? "Building…" : "Build queue"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<dl className="detail-list compact-detail-list">
|
|
||||||
<div>
|
|
||||||
<dt>Version state</dt>
|
|
||||||
<dd>{version?.workflow_state ?? "—"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Lock</dt>
|
|
||||||
<dd>{versionLockReason(version)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Validation</dt>
|
|
||||||
<dd>
|
|
||||||
{validationOk
|
|
||||||
? "Passed"
|
|
||||||
: version?.validation_summary
|
|
||||||
? "Needs attention"
|
|
||||||
: "Not validated"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Build</dt>
|
|
||||||
<dd>
|
|
||||||
{String(
|
|
||||||
buildSummary.built_count ??
|
|
||||||
buildSummary.ready_count ??
|
|
||||||
"Not built",
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
|
||||||
<Card title="Validation summary">
|
|
||||||
<div className="summary-grid">
|
|
||||||
<SummaryTile
|
|
||||||
label="Errors"
|
|
||||||
value={summaryValue(version?.validation_summary, [
|
|
||||||
"error_count",
|
|
||||||
"errors",
|
|
||||||
"blocked",
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<SummaryTile
|
|
||||||
label="Warnings"
|
|
||||||
value={summaryValue(version?.validation_summary, [
|
|
||||||
"warning_count",
|
|
||||||
"warnings",
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<SummaryTile
|
|
||||||
label="Info"
|
|
||||||
value={summaryValue(version?.validation_summary, [
|
|
||||||
"info_count",
|
|
||||||
"info",
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<SummaryTile
|
|
||||||
label="Validated"
|
|
||||||
value={formatDateTime(version?.updated_at)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!version?.validation_summary && (
|
|
||||||
<p className="muted">No validation summary is stored yet.</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Build summary">
|
|
||||||
<div className="summary-grid">
|
|
||||||
<SummaryTile
|
|
||||||
label="Built"
|
|
||||||
value={summaryValue(version?.build_summary, [
|
|
||||||
"built_count",
|
|
||||||
"built",
|
|
||||||
"messages_built",
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<SummaryTile
|
|
||||||
label="Blocked"
|
|
||||||
value={summaryValue(version?.build_summary, [
|
|
||||||
"blocked_count",
|
|
||||||
"blocked",
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<SummaryTile
|
|
||||||
label="Needs review"
|
|
||||||
value={summaryValue(version?.build_summary, [
|
|
||||||
"needs_review_count",
|
|
||||||
"needs_review",
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
<SummaryTile
|
|
||||||
label="Warnings"
|
|
||||||
value={summaryValue(version?.build_summary, [
|
|
||||||
"warning_count",
|
|
||||||
"warnings",
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!version?.build_summary && (
|
|
||||||
<p className="muted">No build summary is stored yet.</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title="Validation issues"
|
|
||||||
actions={
|
|
||||||
issues.length > 0 ? (
|
|
||||||
<span className="muted small-note">{issues.length} issue(s)</span>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{issues.length === 0 && (
|
|
||||||
<p className="muted">
|
|
||||||
No validation issues are stored for this version. Run validation
|
|
||||||
to populate this list.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{issues.length > 0 && (
|
|
||||||
<div className="app-table-wrap data-table-wrap">
|
|
||||||
<table className="app-table data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Severity</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Code</th>
|
|
||||||
<th>Message</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{issues.map((issue, index) => (
|
|
||||||
<tr key={String(issue.issueKey ?? index)}>
|
|
||||||
<td>
|
|
||||||
<StatusBadge
|
|
||||||
status={String(issue.severity || "info")}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{String(
|
|
||||||
issue.path ||
|
|
||||||
issue.source ||
|
|
||||||
issue.section ||
|
|
||||||
issue.field ||
|
|
||||||
"—",
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{String(issue.code || "—")}</td>
|
|
||||||
<td>
|
|
||||||
{String(issue.message || stringifyPreview(issue, 220))}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</LoadingFrame>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryTile({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="summary-tile">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectIssues(...sources: unknown[]): Record<string, unknown>[] {
|
|
||||||
const byKey = new Map<string, Record<string, unknown>>();
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
for (const issue of collectIssueSource(source)) {
|
|
||||||
const normalized = normalizeIssue(issue);
|
|
||||||
const key =
|
|
||||||
String(normalized.severity ?? "") +
|
|
||||||
"|" +
|
|
||||||
String(
|
|
||||||
normalized.path ??
|
|
||||||
normalized.source ??
|
|
||||||
normalized.section ??
|
|
||||||
normalized.field ??
|
|
||||||
"",
|
|
||||||
) +
|
|
||||||
"|" +
|
|
||||||
String(normalized.code ?? "") +
|
|
||||||
"|" +
|
|
||||||
String(normalized.message ?? "");
|
|
||||||
byKey.set(key, { ...normalized, issueKey: key });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byKey.values()).sort(
|
|
||||||
(left, right) => severityRank(left.severity) - severityRank(right.severity),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectIssueSource(raw: unknown): Record<string, unknown>[] {
|
|
||||||
if (Array.isArray(raw)) return raw.map(asRecord);
|
|
||||||
if (!raw || typeof raw !== "object") return [];
|
|
||||||
const record = raw as Record<string, unknown>;
|
|
||||||
const direct = asArray(record.items ?? record.issues ?? record.results);
|
|
||||||
if (direct.length) return direct.map(asRecord);
|
|
||||||
return Object.entries(record).flatMap(([section, value]) =>
|
|
||||||
asArray(value).map((item) => ({ section, ...asRecord(item) })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIssue(
|
|
||||||
issue: Record<string, unknown>,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
...issue,
|
|
||||||
severity: issue.severity ?? issue.level ?? issue.tone ?? "info",
|
|
||||||
path: issue.path ?? issue.source ?? issue.location ?? issue.field,
|
|
||||||
code: issue.code ?? issue.type,
|
|
||||||
message: issue.message ?? issue.detail ?? issue.description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function severityRank(value: unknown): number {
|
|
||||||
switch (String(value || "info").toLowerCase()) {
|
|
||||||
case "error":
|
|
||||||
case "danger":
|
|
||||||
case "blocked":
|
|
||||||
return 0;
|
|
||||||
case "warning":
|
|
||||||
case "warn":
|
|
||||||
return 1;
|
|
||||||
default:
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,8 @@ import PageTitle from "../../components/PageTitle";
|
|||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
|
import ReviewWorkflowCards from "./components/ReviewWorkflowCards";
|
||||||
|
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import MetricCard from "../../components/MetricCard";
|
import MetricCard from "../../components/MetricCard";
|
||||||
import StatusBadge from "../../components/StatusBadge";
|
import StatusBadge from "../../components/StatusBadge";
|
||||||
@@ -157,6 +159,15 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReviewWorkflowCards
|
||||||
|
settings={settings}
|
||||||
|
version={version}
|
||||||
|
summary={data.summary}
|
||||||
|
loading={loading}
|
||||||
|
reload={reload}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card title="Mock delivery test" collapsible actions={<span className="muted small-note">Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.</span>}>
|
<Card title="Mock delivery test" collapsible actions={<span className="muted small-note">Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.</span>}>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void runMockFlow(false, false)} disabled={!version || loading || mockBusy}>{mockBusy ? "Working…" : "Review mock steps"}</Button>
|
<Button onClick={() => void runMockFlow(false, false)} disabled={!version || loading || mockBusy}>{mockBusy ? "Working…" : "Review mock steps"}</Button>
|
||||||
@@ -254,22 +265,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedMockMessage && (
|
|
||||||
<div className="mock-message-detail">
|
|
||||||
<div className="subsection-heading split">
|
|
||||||
<h3>{selectedMockMessage.subject || "Mock message"}</h3>
|
|
||||||
<Button onClick={() => setSelectedMockMessage(null)}>Close details</Button>
|
|
||||||
</div>
|
|
||||||
<div className="detail-grid">
|
|
||||||
<div><span className="muted small-note">From</span><strong>{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}</strong></div>
|
|
||||||
<div><span className="muted small-note">To</span><strong>{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}</strong></div>
|
|
||||||
<div><span className="muted small-note">Size</span><strong>{selectedMockMessage.size_bytes || 0} bytes</strong></div>
|
|
||||||
<div><span className="muted small-note">Folder</span><strong>{selectedMockMessage.folder || "—"}</strong></div>
|
|
||||||
</div>
|
|
||||||
{selectedMockMessage.body_preview && <pre className="mock-message-preview">{selectedMockMessage.body_preview}</pre>}
|
|
||||||
{selectedMockMessage.raw_eml && <details><summary>Raw MIME</summary><pre className="mock-message-raw">{selectedMockMessage.raw_eml}</pre></details>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -343,6 +339,21 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
{selectedMockMessage && (
|
||||||
|
<MessagePreviewOverlay
|
||||||
|
title="Captured mock mail"
|
||||||
|
subject={selectedMockMessage.subject || "Mock message"}
|
||||||
|
bodyMode="text"
|
||||||
|
text={selectedMockMessage.body_preview || ""}
|
||||||
|
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
|
||||||
|
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
|
||||||
|
metaItems={mockMessageMetaItems(selectedMockMessage)}
|
||||||
|
attachments={mockMessageAttachments(selectedMockMessage)}
|
||||||
|
raw={selectedMockMessage.raw_eml}
|
||||||
|
rawLabel="Raw MIME"
|
||||||
|
onClose={() => setSelectedMockMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={sendConfirmOpen}
|
open={sendConfirmOpen}
|
||||||
title="Send this version now?"
|
title="Send this version now?"
|
||||||
@@ -359,3 +370,23 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function mockMessageMetaItems(message: MockMailboxMessage) {
|
||||||
|
return [
|
||||||
|
{ label: "From", value: message.from_header || message.envelope_from || "—" },
|
||||||
|
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
|
||||||
|
{ label: "Kind", value: message.kind || "—" },
|
||||||
|
{ label: "Folder", value: message.folder || "—" },
|
||||||
|
{ label: "Message-ID", value: message.message_id || "—" },
|
||||||
|
{ label: "Size", value: `${message.size_bytes || 0} bytes` }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
|
||||||
|
return (message.attachments ?? []).map((attachment, index) => ({
|
||||||
|
filename: attachment.filename || `Attachment ${index + 1}`,
|
||||||
|
contentType: attachment.content_type || undefined,
|
||||||
|
sizeBytes: attachment.size_bytes ?? undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
@@ -8,6 +7,7 @@ import PageTitle from "../../components/PageTitle";
|
|||||||
import LoadingFrame from "../../components/LoadingFrame";
|
import LoadingFrame from "../../components/LoadingFrame";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
|
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
@@ -233,21 +233,24 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
|||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
{previewOpen && (
|
{previewOpen && (
|
||||||
<TemplatePreviewOverlay
|
<MessagePreviewOverlay
|
||||||
|
title="Template preview"
|
||||||
bodyMode={bodyMode}
|
bodyMode={bodyMode}
|
||||||
entry={previewEntry}
|
|
||||||
index={Math.min(previewIndex, previewEntries.length - 1)}
|
|
||||||
total={previewEntries.length}
|
|
||||||
subject={previewSubject}
|
subject={previewSubject}
|
||||||
text={previewText}
|
text={previewText}
|
||||||
html={previewHtml}
|
html={previewHtml}
|
||||||
hasRealRecipients={inlineEntries.length > 0}
|
recipientLabel={inlineEntries.length > 0 ? recipientLabel(previewEntry, Math.min(previewIndex, previewEntries.length - 1)) : "Global preview"}
|
||||||
attachments={previewAttachments}
|
recipientNote={inlineEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : "No inline recipients are available yet."}
|
||||||
|
attachments={mapEffectiveAttachmentsToPreviewBoxes(previewAttachments)}
|
||||||
|
navigation={{
|
||||||
|
index: Math.min(previewIndex, previewEntries.length - 1),
|
||||||
|
total: previewEntries.length,
|
||||||
|
onFirst: () => setPreviewIndex(0),
|
||||||
|
onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)),
|
||||||
|
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
|
||||||
|
onLast: () => setPreviewIndex(previewEntries.length - 1)
|
||||||
|
}}
|
||||||
onClose={() => setPreviewOpen(false)}
|
onClose={() => setPreviewOpen(false)}
|
||||||
onFirst={() => setPreviewIndex(0)}
|
|
||||||
onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))}
|
|
||||||
onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))}
|
|
||||||
onLast={() => setPreviewIndex(previewEntries.length - 1)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -316,113 +319,31 @@ function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceho
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplatePreviewOverlay({
|
function mapEffectiveAttachmentsToPreviewBoxes(attachments: EffectiveAttachmentPreview[]): MessagePreviewAttachment[] {
|
||||||
bodyMode,
|
return attachments.flatMap((attachment) => {
|
||||||
entry,
|
const detailParts = [
|
||||||
index,
|
attachment.scope === "global" ? "Global" : "Recipient",
|
||||||
total,
|
attachment.label,
|
||||||
subject,
|
attachment.required ? "required" : "optional",
|
||||||
text,
|
attachment.includeSubdirs ? "subdirs" : ""
|
||||||
html,
|
].filter(Boolean);
|
||||||
hasRealRecipients,
|
const detail = detailParts.join(" · ");
|
||||||
attachments,
|
if (attachment.matches.length > 0) {
|
||||||
onClose,
|
return attachment.matches.map((match) => ({
|
||||||
onFirst,
|
filename: match,
|
||||||
onPrevious,
|
label: attachment.label,
|
||||||
onNext,
|
detail
|
||||||
onLast
|
}));
|
||||||
}: {
|
}
|
||||||
bodyMode: BodyMode;
|
return [{
|
||||||
entry: Record<string, unknown>;
|
filename: attachment.fileFilter || "No matched file",
|
||||||
index: number;
|
label: attachment.label,
|
||||||
total: number;
|
detail: `${detail}${detail ? " · " : ""}${attachment.basePathName || attachment.basePath || "attachment source"}`
|
||||||
subject: string;
|
}];
|
||||||
text: string;
|
});
|
||||||
html: string;
|
|
||||||
hasRealRecipients: boolean;
|
|
||||||
attachments: EffectiveAttachmentPreview[];
|
|
||||||
onClose: () => void;
|
|
||||||
onFirst: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onLast: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="template-preview-title">
|
|
||||||
<div className="modal-panel template-preview-modal">
|
|
||||||
<header className="modal-header">
|
|
||||||
<h2 id="template-preview-title">Template preview</h2>
|
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
|
||||||
</header>
|
|
||||||
<div className="modal-body">
|
|
||||||
<div className="template-preview-toolbar">
|
|
||||||
<div>
|
|
||||||
<strong>{hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}</strong>
|
|
||||||
<p className="muted small-note">{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}</p>
|
|
||||||
</div>
|
|
||||||
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
|
||||||
<button type="button" className="version-arrow" onClick={onFirst} disabled={index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
|
|
||||||
<button type="button" className="version-arrow" onClick={onPrevious} disabled={index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
|
||||||
<span className="template-preview-count">{index + 1} / {total}</span>
|
|
||||||
<button type="button" className="version-arrow" onClick={onNext} disabled={index >= total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
|
|
||||||
<button type="button" className="version-arrow" onClick={onLast} disabled={index >= total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="template-preview-box">
|
|
||||||
<h3>{subject || "No subject"}</h3>
|
|
||||||
{bodyMode === "html" ? (
|
|
||||||
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={html || "<p>No HTML body to preview.</p>"} />
|
|
||||||
) : (
|
|
||||||
<pre>{text || "No plain-text body to preview."}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<EffectiveAttachmentPreviewTable attachments={attachments} />
|
|
||||||
</div>
|
|
||||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function EffectiveAttachmentPreviewTable({ attachments }: { attachments: EffectiveAttachmentPreview[] }) {
|
|
||||||
return (
|
|
||||||
<div className="template-preview-attachments">
|
|
||||||
<h3>Effective attachments</h3>
|
|
||||||
{attachments.length === 0 ? (
|
|
||||||
<p className="muted small-note">No global or recipient attachments are effective for this message.</p>
|
|
||||||
) : (
|
|
||||||
<div className="app-table-wrap">
|
|
||||||
<table className="app-table compact-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Scope</th>
|
|
||||||
<th>Label</th>
|
|
||||||
<th>Base path</th>
|
|
||||||
<th>File / pattern</th>
|
|
||||||
<th>Options</th>
|
|
||||||
<th>Preview match</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{attachments.map((attachment, index) => (
|
|
||||||
<tr key={`${attachment.scope}:${attachment.basePath}:${attachment.fileFilter}:${index}`}>
|
|
||||||
<td>{attachment.scope === "global" ? "Global" : "Recipient"}</td>
|
|
||||||
<td>{attachment.label}</td>
|
|
||||||
<td>{attachment.basePathName}<br /><span className="muted"><code>{attachment.basePath}</code></span></td>
|
|
||||||
<td><code>{attachment.fileFilter || "—"}</code></td>
|
|
||||||
<td>{attachment.required ? "required" : "optional"}{attachment.includeSubdirs ? ", subdirs" : ""}</td>
|
|
||||||
<td>{attachment.matches.length > 0 ? attachment.matches.join(", ") : "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||||
const name = valueToPreview(entry.name).trim();
|
const name = valueToPreview(entry.name).trim();
|
||||||
const email = valueToPreview(entry.email).trim();
|
const email = valueToPreview(entry.email).trim();
|
||||||
|
|||||||
143
src/features/campaigns/components/MessagePreviewOverlay.tsx
Normal file
143
src/features/campaigns/components/MessagePreviewOverlay.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||||
|
import Button from "../../../components/Button";
|
||||||
|
|
||||||
|
export type MessagePreviewAttachment = {
|
||||||
|
filename?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
sizeBytes?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessagePreviewMetaItem = {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessagePreviewNavigation = {
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
onFirst: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onLast: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessagePreviewOverlayProps = {
|
||||||
|
title?: string;
|
||||||
|
subject?: string | null;
|
||||||
|
bodyMode?: "text" | "html";
|
||||||
|
text?: string | null;
|
||||||
|
html?: string | null;
|
||||||
|
recipientLabel?: React.ReactNode;
|
||||||
|
recipientNote?: React.ReactNode;
|
||||||
|
metaItems?: MessagePreviewMetaItem[];
|
||||||
|
attachments?: MessagePreviewAttachment[];
|
||||||
|
raw?: string | null;
|
||||||
|
rawLabel?: string;
|
||||||
|
navigation?: MessagePreviewNavigation;
|
||||||
|
closeLabel?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MessagePreviewOverlay({
|
||||||
|
title = "Message preview",
|
||||||
|
subject,
|
||||||
|
bodyMode = "text",
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
recipientLabel,
|
||||||
|
recipientNote,
|
||||||
|
metaItems = [],
|
||||||
|
attachments = [],
|
||||||
|
raw,
|
||||||
|
rawLabel = "Raw MIME",
|
||||||
|
navigation,
|
||||||
|
closeLabel = "Close",
|
||||||
|
onClose
|
||||||
|
}: MessagePreviewOverlayProps) {
|
||||||
|
const shownSubject = subject?.trim() || "No subject";
|
||||||
|
const shownText = text?.trim() || "No plain-text body to preview.";
|
||||||
|
const shownHtml = html?.trim() || "<p>No HTML body to preview.</p>";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
||||||
|
<div className="modal-panel template-preview-modal message-preview-modal">
|
||||||
|
<header className="modal-header">
|
||||||
|
<h2 id="message-preview-title">{title}</h2>
|
||||||
|
<button className="modal-close" onClick={onClose}>×</button>
|
||||||
|
</header>
|
||||||
|
<div className="modal-body">
|
||||||
|
{(recipientLabel || recipientNote || navigation) && (
|
||||||
|
<div className="template-preview-toolbar">
|
||||||
|
<div>
|
||||||
|
{recipientLabel && <strong>{recipientLabel}</strong>}
|
||||||
|
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
||||||
|
</div>
|
||||||
|
{navigation && (
|
||||||
|
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
||||||
|
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||||
|
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
||||||
|
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
||||||
|
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
|
||||||
|
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metaItems.length > 0 && (
|
||||||
|
<div className="detail-grid message-preview-meta">
|
||||||
|
{metaItems.map((item) => (
|
||||||
|
<div key={item.label}><span className="muted small-note">{item.label}</span><strong>{item.value || "—"}</strong></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="template-preview-box">
|
||||||
|
<h3>{shownSubject}</h3>
|
||||||
|
{bodyMode === "html" ? (
|
||||||
|
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={shownHtml} />
|
||||||
|
) : (
|
||||||
|
<pre>{shownText}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessagePreviewAttachmentBoxes attachments={attachments} />
|
||||||
|
|
||||||
|
{raw && (
|
||||||
|
<details className="message-preview-raw">
|
||||||
|
<summary>{rawLabel}</summary>
|
||||||
|
<pre className="mock-message-raw">{raw}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
|
||||||
|
return (
|
||||||
|
<div className="template-preview-attachments message-preview-attachments">
|
||||||
|
<h3>Attachments</h3>
|
||||||
|
{attachments.length === 0 ? (
|
||||||
|
<p className="muted small-note">No attachments are effective for this message.</p>
|
||||||
|
) : (
|
||||||
|
<div className="attachment-chip-grid">
|
||||||
|
{attachments.map((attachment, index) => {
|
||||||
|
const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
|
||||||
|
const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
|
||||||
|
return (
|
||||||
|
<div className="attachment-file-chip" key={`${filename}:${index}`}>
|
||||||
|
<strong>{filename}</strong>
|
||||||
|
{details && <span>{details}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
src/features/campaigns/components/ReviewWorkflowCards.tsx
Normal file
384
src/features/campaigns/components/ReviewWorkflowCards.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ApiSettings } from "../../../types";
|
||||||
|
import type { CampaignSummary, CampaignVersionDetail } from "../../../api/campaigns";
|
||||||
|
import { buildVersion, validateVersion } from "../../../api/campaigns";
|
||||||
|
import Button from "../../../components/Button";
|
||||||
|
import Card from "../../../components/Card";
|
||||||
|
import StatusBadge from "../../../components/StatusBadge";
|
||||||
|
import {
|
||||||
|
asArray,
|
||||||
|
asRecord,
|
||||||
|
formatDateTime,
|
||||||
|
isFinalLockedVersion,
|
||||||
|
isUserLockedVersion,
|
||||||
|
isVersionReadyForDelivery,
|
||||||
|
stringifyPreview,
|
||||||
|
summaryValue,
|
||||||
|
versionLockReason,
|
||||||
|
} from "../utils/campaignView";
|
||||||
|
|
||||||
|
type ReviewWorkflowCardsProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
version: CampaignVersionDetail | null;
|
||||||
|
summary?: CampaignSummary | null;
|
||||||
|
loading?: boolean;
|
||||||
|
reload: () => Promise<void> | void;
|
||||||
|
setError: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReviewWorkflowCards({
|
||||||
|
settings,
|
||||||
|
version,
|
||||||
|
summary,
|
||||||
|
loading = false,
|
||||||
|
reload,
|
||||||
|
setError,
|
||||||
|
}: ReviewWorkflowCardsProps) {
|
||||||
|
const auditSafe =
|
||||||
|
isUserLockedVersion(version) || isFinalLockedVersion(version);
|
||||||
|
const validationSummary = asRecord(version?.validation_summary);
|
||||||
|
const buildSummary = asRecord(version?.build_summary);
|
||||||
|
const validationOk = validationSummary.ok === true;
|
||||||
|
const readyForDelivery = isVersionReadyForDelivery(version);
|
||||||
|
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "">("");
|
||||||
|
const [actionMessage, setActionMessage] = useState("");
|
||||||
|
const [lastValidationResult, setLastValidationResult] = useState<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLastValidationResult(null);
|
||||||
|
setActionMessage("");
|
||||||
|
}, [version?.id]);
|
||||||
|
|
||||||
|
const issues = useMemo(
|
||||||
|
() =>
|
||||||
|
collectIssues(
|
||||||
|
lastValidationResult,
|
||||||
|
validationSummary,
|
||||||
|
summary?.issues,
|
||||||
|
),
|
||||||
|
[lastValidationResult, validationSummary, summary?.issues],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function runValidate() {
|
||||||
|
if (!version || actionBusy) return;
|
||||||
|
setActionBusy("validate");
|
||||||
|
setActionMessage("Validating campaign and locking the version on success…");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await validateVersion(settings, version.id, false);
|
||||||
|
setLastValidationResult(result);
|
||||||
|
setActionMessage(
|
||||||
|
result.ok
|
||||||
|
? "Validation passed. This version is now locked but can still be unlocked before sending."
|
||||||
|
: "Validation finished with issues. See the validation issues below, fix the campaign, and validate again.",
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionMessage("");
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setActionBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBuild() {
|
||||||
|
if (!version || actionBusy) return;
|
||||||
|
setActionBusy("build");
|
||||||
|
setActionMessage("Building the queue for the locked, validated version…");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await buildVersion(settings, version.id, true);
|
||||||
|
setActionMessage(
|
||||||
|
`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`,
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
setActionMessage("");
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setActionBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{actionMessage && <div className="alert info">{actionMessage}</div>}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Review actions"
|
||||||
|
actions={
|
||||||
|
<span className="muted small-note">
|
||||||
|
Validation locks this version; unlocking invalidates validation
|
||||||
|
before sending.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void runValidate()}
|
||||||
|
disabled={
|
||||||
|
!version ||
|
||||||
|
loading ||
|
||||||
|
Boolean(actionBusy) ||
|
||||||
|
readyForDelivery ||
|
||||||
|
auditSafe
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{actionBusy === "validate"
|
||||||
|
? "Validating…"
|
||||||
|
: readyForDelivery
|
||||||
|
? "Validated and locked"
|
||||||
|
: validationOk
|
||||||
|
? "Validate again"
|
||||||
|
: "Validate and lock"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void runBuild()}
|
||||||
|
disabled={
|
||||||
|
!version || loading || Boolean(actionBusy) || !readyForDelivery
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{actionBusy === "build" ? "Building…" : "Build queue"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<dl className="detail-list compact-detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>Version state</dt>
|
||||||
|
<dd>{version?.workflow_state ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Lock</dt>
|
||||||
|
<dd>{versionLockReason(version)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Validation</dt>
|
||||||
|
<dd>
|
||||||
|
{validationOk
|
||||||
|
? "Passed"
|
||||||
|
: version?.validation_summary
|
||||||
|
? "Needs attention"
|
||||||
|
: "Not validated"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Build</dt>
|
||||||
|
<dd>
|
||||||
|
{String(
|
||||||
|
buildSummary.built_count ??
|
||||||
|
buildSummary.ready_count ??
|
||||||
|
"Not built",
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<Card title="Validation summary">
|
||||||
|
<div className="summary-grid">
|
||||||
|
<SummaryTile
|
||||||
|
label="Errors"
|
||||||
|
value={summaryValue(version?.validation_summary, [
|
||||||
|
"error_count",
|
||||||
|
"errors",
|
||||||
|
"blocked",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
label="Warnings"
|
||||||
|
value={summaryValue(version?.validation_summary, [
|
||||||
|
"warning_count",
|
||||||
|
"warnings",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
label="Info"
|
||||||
|
value={summaryValue(version?.validation_summary, [
|
||||||
|
"info_count",
|
||||||
|
"info",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<SummaryTile label="Validated" value={formatDateTime(version?.updated_at)} />
|
||||||
|
</div>
|
||||||
|
{!version?.validation_summary && (
|
||||||
|
<p className="muted">No validation summary is stored yet.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Build summary">
|
||||||
|
<div className="summary-grid">
|
||||||
|
<SummaryTile
|
||||||
|
label="Built"
|
||||||
|
value={summaryValue(version?.build_summary, [
|
||||||
|
"built_count",
|
||||||
|
"built",
|
||||||
|
"messages_built",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
label="Blocked"
|
||||||
|
value={summaryValue(version?.build_summary, [
|
||||||
|
"blocked_count",
|
||||||
|
"blocked",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
label="Needs review"
|
||||||
|
value={summaryValue(version?.build_summary, [
|
||||||
|
"needs_review_count",
|
||||||
|
"needs_review",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
label="Warnings"
|
||||||
|
value={summaryValue(version?.build_summary, [
|
||||||
|
"warning_count",
|
||||||
|
"warnings",
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!version?.build_summary && (
|
||||||
|
<p className="muted">No build summary is stored yet.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Validation issues"
|
||||||
|
actions={
|
||||||
|
issues.length > 0 ? (
|
||||||
|
<span className="muted small-note">{issues.length} issue(s)</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{issues.length === 0 && (
|
||||||
|
<p className="muted">
|
||||||
|
No validation issues are stored for this version. Run validation to
|
||||||
|
populate this list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className="app-table-wrap data-table-wrap">
|
||||||
|
<table className="app-table data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{issues.map((issue, index) => (
|
||||||
|
<tr key={String(issue.issueKey ?? index)}>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={String(issue.severity || "info")} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{String(
|
||||||
|
issue.path ||
|
||||||
|
issue.source ||
|
||||||
|
issue.section ||
|
||||||
|
issue.field ||
|
||||||
|
"—",
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{String(issue.code || "—")}</td>
|
||||||
|
<td>
|
||||||
|
{String(issue.message || stringifyPreview(issue, 220))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="summary-tile">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectIssues(...sources: unknown[]): Record<string, unknown>[] {
|
||||||
|
const byKey = new Map<string, Record<string, unknown>>();
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const issue of collectIssueSource(source)) {
|
||||||
|
const normalized = normalizeIssue(issue);
|
||||||
|
const key =
|
||||||
|
String(normalized.severity ?? "") +
|
||||||
|
"|" +
|
||||||
|
String(
|
||||||
|
normalized.path ??
|
||||||
|
normalized.source ??
|
||||||
|
normalized.section ??
|
||||||
|
normalized.field ??
|
||||||
|
"",
|
||||||
|
) +
|
||||||
|
"|" +
|
||||||
|
String(normalized.code ?? "") +
|
||||||
|
"|" +
|
||||||
|
String(normalized.message ?? "");
|
||||||
|
byKey.set(key, { ...normalized, issueKey: key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byKey.values()).sort(
|
||||||
|
(left, right) => severityRank(left.severity) - severityRank(right.severity),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectIssueSource(raw: unknown): Record<string, unknown>[] {
|
||||||
|
if (Array.isArray(raw)) return raw.map(asRecord);
|
||||||
|
if (!raw || typeof raw !== "object") return [];
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const direct = asArray(record.items ?? record.issues ?? record.results);
|
||||||
|
if (direct.length) return direct.map(asRecord);
|
||||||
|
return Object.entries(record).flatMap(([section, value]) =>
|
||||||
|
asArray(value).map((item) => ({ section, ...asRecord(item) })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIssue(
|
||||||
|
issue: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...issue,
|
||||||
|
severity: issue.severity ?? issue.level ?? issue.tone ?? "info",
|
||||||
|
path: issue.path ?? issue.source ?? issue.location ?? issue.field,
|
||||||
|
code: issue.code ?? issue.type,
|
||||||
|
message: issue.message ?? issue.detail ?? issue.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityRank(value: unknown): number {
|
||||||
|
switch (String(value || "info").toLowerCase()) {
|
||||||
|
case "error":
|
||||||
|
case "danger":
|
||||||
|
case "blocked":
|
||||||
|
return 0;
|
||||||
|
case "warning":
|
||||||
|
case "warn":
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,7 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ id: "mail-settings", label: "Server settings" },
|
{ id: "mail-settings", label: "Server settings" },
|
||||||
{ id: "global-settings", label: "Global settings" },
|
{ id: "global-settings", label: "Global settings" },
|
||||||
{ id: "review", label: "Review" },
|
{ id: "send", label: "Review & Send" },
|
||||||
{ id: "send", label: "Send" },
|
|
||||||
{ id: "report", label: "Report" },
|
{ id: "report", label: "Report" },
|
||||||
{ id: "audit", label: "Audit log" }
|
{ id: "audit", label: "Audit log" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1186,3 +1186,59 @@
|
|||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared message preview overlay --------------------------------------- */
|
||||||
|
.message-preview-modal .modal-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview-meta {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview-attachments h3,
|
||||||
|
.message-preview-raw summary {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-chip-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.18rem;
|
||||||
|
min-width: min(220px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid var(--line-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.68);
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-chip strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-chip span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview-raw {
|
||||||
|
border-top: 1px solid var(--line-subtle);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview-raw summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user