feat: add campaign copying scheduling and residual handling
This commit is contained in:
@@ -7,6 +7,7 @@ import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
@@ -56,6 +57,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
||||
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
|
||||
const residualFiles = asRecord(attachments.residual_files);
|
||||
const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none";
|
||||
const residualRecipient = asRecord(residualFiles.recipient);
|
||||
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
|
||||
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
|
||||
const zipArchiveNameValidation = useMemo(
|
||||
@@ -96,6 +100,18 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function patchResidualFiles(next: Record<string, unknown>) {
|
||||
if (locked) return;
|
||||
patch(["attachments", "residual_files"], {
|
||||
mode: "none",
|
||||
recipient: null,
|
||||
subject: "Unassigned files in campaign {{local:campaign_name}}",
|
||||
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}",
|
||||
...residualFiles,
|
||||
...next
|
||||
});
|
||||
}
|
||||
|
||||
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
|
||||
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
|
||||
}
|
||||
@@ -271,6 +287,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Unassigned file disposition" collapsible>
|
||||
<div className="campaign-residual-file-form">
|
||||
<FormField label="Action" help="Only sources with Unsent enabled are inspected. The existing warning policy remains active when no disposition is selected.">
|
||||
<select value={residualMode} disabled={locked} onChange={(event) => {
|
||||
const mode = event.target.value;
|
||||
patchResidualFiles({
|
||||
mode,
|
||||
recipient: mode === "none" ? null : {
|
||||
email: String(residualRecipient.email ?? ""),
|
||||
name: String(residualRecipient.name ?? "") || null
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<option value="none">Apply warning policy only</option>
|
||||
<option value="report">Prepare report message</option>
|
||||
<option value="attach">Prepare report with files attached</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{residualMode !== "none" && <>
|
||||
<FormField label="Recipient email">
|
||||
<input type="email" value={String(residualRecipient.email ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, email: event.target.value } })} />
|
||||
</FormField>
|
||||
<FormField label="Recipient name">
|
||||
<input value={String(residualRecipient.name ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, name: event.target.value || null } })} />
|
||||
</FormField>
|
||||
<div className="campaign-residual-file-wide">
|
||||
<FormField label="Subject">
|
||||
<input value={String(residualFiles.subject ?? "Unassigned files in campaign {{local:campaign_name}}") } disabled={locked} onChange={(event) => patchResidualFiles({ subject: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="campaign-residual-file-wide">
|
||||
<FormField label="Report text" help="Available values: {{local:campaign_name}}, {{local:residual_file_count}}, and {{local:residual_file_list}}.">
|
||||
<textarea rows={5} value={String(residualFiles.text ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ text: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
{residualMode !== "none" && <DismissibleAlert tone="info" dismissible={false} compact>
|
||||
A residual-file message becomes a separate campaign row that always needs review. It uses the normal build, approval, queue, delivery, report, and audit lifecycle; saving this setting never sends it.
|
||||
</DismissibleAlert>}
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||
<div className="attachment-zip-master-toggle">
|
||||
<ToggleSwitch
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
|
||||
import { Archive, CalendarClock, Copy, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
@@ -17,13 +18,19 @@ import {
|
||||
archiveCampaign,
|
||||
archiveCampaignVersion,
|
||||
copyCampaign,
|
||||
createCampaignSchedule,
|
||||
deleteCampaign,
|
||||
getCampaignLifecyclePolicy,
|
||||
lockCampaignVersionPermanently,
|
||||
lockCampaignVersionTemporarily,
|
||||
unlockCampaignVersionUserLock,
|
||||
listCampaignSchedules,
|
||||
setCampaignScheduleState,
|
||||
updateCampaignMetadata,
|
||||
type CampaignSchedule,
|
||||
type CampaignScheduleCreate,
|
||||
type CampaignLifecyclePolicy,
|
||||
type CampaignCopyOptions,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem } from
|
||||
"../../api/campaigns";
|
||||
@@ -51,6 +58,34 @@ type PendingLifecycleAction = {
|
||||
policy: CampaignLifecyclePolicy;
|
||||
version?: CampaignVersionListItem;
|
||||
} | null;
|
||||
const defaultCopyOptions: CampaignCopyOptions = {
|
||||
name: "",
|
||||
external_id: "",
|
||||
include_recipients: true,
|
||||
include_files: true,
|
||||
include_shares: false,
|
||||
include_policies: true,
|
||||
include_mail_profile: true
|
||||
};
|
||||
|
||||
function defaultScheduleDraft(): CampaignScheduleCreate {
|
||||
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||
start.setSeconds(0, 0);
|
||||
return {
|
||||
source_version_id: "",
|
||||
name: "",
|
||||
recurrence_kind: "once",
|
||||
interval_count: 1,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
starts_at: localDateTimeValue(start),
|
||||
max_occurrences: 1,
|
||||
include_recipients: true,
|
||||
include_files: true,
|
||||
include_shares: false,
|
||||
include_policies: true,
|
||||
include_mail_profile: true
|
||||
};
|
||||
}
|
||||
|
||||
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
@@ -65,12 +100,18 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||
const [lockBusy, setLockBusy] = useState(false);
|
||||
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
|
||||
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
|
||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||
const [scheduleDraft, setScheduleDraft] = useState<CampaignScheduleCreate>(defaultScheduleDraft);
|
||||
const [scheduleBusy, setScheduleBusy] = useState(false);
|
||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy") && hasScope(auth, "campaigns:recipient:read");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
||||
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: identityDirty,
|
||||
@@ -97,6 +138,20 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
});
|
||||
}, [campaign, identityDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaign?.id) {
|
||||
setSchedules([]);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void listCampaignSchedules(settings, campaign.id).then((items) => {
|
||||
if (active) setSchedules(items);
|
||||
}).catch((err) => {
|
||||
if (active) setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [campaign?.id, settings, setError]);
|
||||
|
||||
function patchIdentity(key: keyof typeof identity, value: string) {
|
||||
setIdentity((current) => ({ ...current, [key]: value }));
|
||||
setIdentityDirty(true);
|
||||
@@ -177,6 +232,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
|
||||
return;
|
||||
}
|
||||
if (action === "copy_campaign") {
|
||||
setCopyOptions({
|
||||
...defaultCopyOptions,
|
||||
name: `${campaign.name} (copy)`,
|
||||
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
||||
});
|
||||
}
|
||||
setPendingLifecycleAction({ action, policy, version });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -201,7 +263,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
navigate("/campaigns");
|
||||
return;
|
||||
} else if (pending.action === "copy_campaign" && pending.version) {
|
||||
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token);
|
||||
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token, copyOptions);
|
||||
setPendingLifecycleAction(null);
|
||||
navigate(`/campaigns/${created.campaign.id}`);
|
||||
return;
|
||||
@@ -218,6 +280,57 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
}
|
||||
}
|
||||
|
||||
function openScheduleDialog() {
|
||||
if (!campaign || !data.currentVersion) return;
|
||||
setScheduleDraft({
|
||||
...defaultScheduleDraft(),
|
||||
source_version_id: data.currentVersion.id,
|
||||
name: campaign.name,
|
||||
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
||||
});
|
||||
setScheduleDialogOpen(true);
|
||||
}
|
||||
|
||||
async function submitSchedule() {
|
||||
if (!campaign || scheduleBusy) return;
|
||||
setScheduleBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createCampaignSchedule(settings, campaign.id, {
|
||||
...scheduleDraft,
|
||||
starts_at: new Date(scheduleDraft.starts_at).toISOString(),
|
||||
max_occurrences: scheduleDraft.recurrence_kind === "once" ? 1 : scheduleDraft.max_occurrences
|
||||
});
|
||||
setSchedules((current) => [created, ...current]);
|
||||
setScheduleDialogOpen(false);
|
||||
setMessage("Campaign schedule created. Each occurrence prepares a fresh draft for review; it does not send automatically.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setScheduleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSchedule(schedule: CampaignSchedule) {
|
||||
if (!campaign || scheduleBusy) return;
|
||||
setScheduleBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await setCampaignScheduleState(
|
||||
settings,
|
||||
campaign.id,
|
||||
schedule.id,
|
||||
!schedule.active,
|
||||
schedule.resource_revision
|
||||
);
|
||||
setSchedules((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setScheduleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
@@ -233,6 +346,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
Copy campaign
|
||||
</Button>}
|
||||
{canSchedule && <Button
|
||||
onClick={openScheduleDialog}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "Save or discard overview changes before scheduling." : undefined}>
|
||||
<CalendarClock size={16} aria-hidden="true" />
|
||||
Schedule
|
||||
</Button>}
|
||||
{canDelete && <Button
|
||||
variant="danger"
|
||||
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
||||
@@ -287,6 +407,37 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{(canSchedule || schedules.length > 0) && <Card
|
||||
title="Schedules"
|
||||
collapsible
|
||||
actions={canSchedule ? <Button onClick={openScheduleDialog}>
|
||||
<CalendarClock size={16} aria-hidden="true" />
|
||||
Add schedule
|
||||
</Button> : undefined}>
|
||||
<p className="muted small-note">Due occurrences prepare independent campaign drafts. Validation, review, approval, and delivery are never started by the schedule.</p>
|
||||
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
||||
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
||||
<div className="campaign-schedule-main">
|
||||
<strong>{schedule.name}</strong>
|
||||
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} prepared</span>
|
||||
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
||||
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>Open latest draft</Link>}
|
||||
{schedule.next_fire_at && <Button
|
||||
iconOnly
|
||||
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||
title={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||
disabled={scheduleBusy || !canSchedule}
|
||||
onClick={() => void toggleSchedule(schedule)}>
|
||||
{schedule.active ? <Pause size={16} aria-hidden="true" /> : <Play size={16} aria-hidden="true" />}
|
||||
</Button>}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>}
|
||||
</Card>}
|
||||
|
||||
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
|
||||
{archivedVersionCount > 0 && <ToggleSwitch
|
||||
label={`Show archived (${archivedVersionCount})`}
|
||||
@@ -335,7 +486,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
</LoadingFrame>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingLifecycleAction)}
|
||||
open={Boolean(pendingLifecycleAction && pendingLifecycleAction.action !== "copy_campaign")}
|
||||
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
||||
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
||||
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
||||
@@ -344,6 +495,119 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
onCancel={() => setPendingLifecycleAction(null)}
|
||||
onConfirm={() => void applyLifecycleAction()} />
|
||||
|
||||
<Dialog
|
||||
open={pendingLifecycleAction?.action === "copy_campaign"}
|
||||
title="Copy campaign"
|
||||
className="campaign-copy-dialog"
|
||||
helpContextId="campaigns.action.copy-campaign"
|
||||
closeDisabled={lifecycleBusy}
|
||||
onClose={() => setPendingLifecycleAction(null)}
|
||||
footer={<>
|
||||
<Button onClick={() => setPendingLifecycleAction(null)} disabled={lifecycleBusy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void applyLifecycleAction()} disabled={lifecycleBusy || !copyOptions.name?.trim()}>
|
||||
{lifecycleBusy ? "Creating copy..." : "Create copy"}
|
||||
</Button>
|
||||
</>}>
|
||||
<div className="campaign-copy-form">
|
||||
<p className="muted small-note">
|
||||
Create a new draft from version #{pendingLifecycleAction?.version?.version_number ?? "?"}. Delivery jobs, outcomes, locks, reports, and audit evidence are never copied.
|
||||
</p>
|
||||
<div className="campaign-copy-identity">
|
||||
<FormField label="Campaign name" help="The new campaign receives an independent identity and version history.">
|
||||
<input value={copyOptions.name ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, name: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="Campaign ID" help="Leave blank to generate a unique ID from the source campaign.">
|
||||
<input value={copyOptions.external_id ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, external_id: event.target.value }))} placeholder="Generated automatically" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="campaign-copy-options">
|
||||
<CopyOption
|
||||
label="Recipients"
|
||||
detail="Global address headers, recipient rows, imported audience provenance, and per-recipient values."
|
||||
checked={copyOptions.include_recipients}
|
||||
disabled={!hasScope(auth, "campaigns:recipient:read")}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_recipients: checked }))} />
|
||||
<CopyOption
|
||||
label="Files"
|
||||
detail="Campaign and recipient attachment rules. Managed files remain owned by Files and are referenced, not duplicated."
|
||||
checked={copyOptions.include_files}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_files: checked }))} />
|
||||
<CopyOption
|
||||
label="Shares"
|
||||
detail="Current active user and group shares. Ownership always starts with the account creating the copy."
|
||||
checked={copyOptions.include_shares}
|
||||
disabled={!hasScope(auth, "campaigns:campaign:share")}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_shares: checked }))} />
|
||||
<CopyOption
|
||||
label="Policies"
|
||||
detail="Validation behavior and campaign settings."
|
||||
checked={copyOptions.include_policies}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_policies: checked }))} />
|
||||
<CopyOption
|
||||
label="Mail profile"
|
||||
detail="References to the reusable Mail profile, its campaign policy, server selections, and credential selections. Secrets are never copied into Campaign."
|
||||
checked={copyOptions.include_mail_profile}
|
||||
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={scheduleDialogOpen}
|
||||
title="Schedule campaign drafts"
|
||||
className="campaign-schedule-dialog"
|
||||
helpContextId="campaigns.action.schedule-drafts"
|
||||
closeDisabled={scheduleBusy}
|
||||
onClose={() => setScheduleDialogOpen(false)}
|
||||
footer={<>
|
||||
<Button onClick={() => setScheduleDialogOpen(false)} disabled={scheduleBusy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void submitSchedule()} disabled={scheduleBusy || !scheduleDraft.name.trim() || !scheduleDraft.starts_at}>
|
||||
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
||||
</Button>
|
||||
</>}>
|
||||
<DismissibleAlert tone="info" resetKey="campaign-schedule-safety">
|
||||
A schedule creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically.
|
||||
</DismissibleAlert>
|
||||
<div className="campaign-schedule-form">
|
||||
<FormField label="Draft name">
|
||||
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="First occurrence">
|
||||
<input type="datetime-local" value={scheduleDraft.starts_at} onChange={(event) => setScheduleDraft((current) => ({ ...current, starts_at: event.target.value }))} />
|
||||
</FormField>
|
||||
<FormField label="Recurrence">
|
||||
<select value={scheduleDraft.recurrence_kind} onChange={(event) => {
|
||||
const recurrence_kind = event.target.value as CampaignSchedule["recurrence_kind"];
|
||||
setScheduleDraft((current) => ({ ...current, recurrence_kind, max_occurrences: recurrence_kind === "once" ? 1 : Math.max(2, current.max_occurrences) }));
|
||||
}}>
|
||||
<option value="once">Once</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Every">
|
||||
<div className="campaign-schedule-interval">
|
||||
<input type="number" min={1} max={365} value={scheduleDraft.interval_count} onChange={(event) => setScheduleDraft((current) => ({ ...current, interval_count: Math.max(1, Number(event.target.value) || 1) }))} />
|
||||
<span>{scheduleDraft.recurrence_kind.replace("ly", "")} interval(s)</span>
|
||||
</div>
|
||||
</FormField>}
|
||||
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Maximum occurrences" help="Recurring schedules are bounded. Create a new schedule if the approved plan changes.">
|
||||
<input type="number" min={2} max={1000} value={scheduleDraft.max_occurrences} onChange={(event) => setScheduleDraft((current) => ({ ...current, max_occurrences: Math.max(2, Number(event.target.value) || 2) }))} />
|
||||
</FormField>}
|
||||
<FormField label="Timezone">
|
||||
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="campaign-copy-options">
|
||||
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
||||
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
||||
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
||||
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
||||
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingLockAction)}
|
||||
title={lockDialogTitle(pendingLockAction)}
|
||||
@@ -358,6 +622,42 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
|
||||
}
|
||||
|
||||
function localDateTimeValue(value: Date): string {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function scheduleCadence(schedule: CampaignSchedule): string {
|
||||
if (schedule.recurrence_kind === "once") return "One time";
|
||||
const unit = { daily: "day", weekly: "week", monthly: "month" }[schedule.recurrence_kind];
|
||||
return schedule.interval_count === 1 ? schedule.recurrence_kind : `Every ${schedule.interval_count} ${unit}s`;
|
||||
}
|
||||
|
||||
function CopyOption({
|
||||
label,
|
||||
detail,
|
||||
checked,
|
||||
disabled = false,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <div className={`campaign-copy-option ${disabled ? "is-disabled" : ""}`}>
|
||||
<div><strong>{label}</strong><small>{detail}</small></div>
|
||||
<ToggleSwitch
|
||||
label={`Copy ${label.toLowerCase()}`}
|
||||
inactiveLabel="Exclude"
|
||||
activeLabel="Include"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={onChange} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||
|
||||
type CampaignVersionMetrics = {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
listCampaignContentLibrary,
|
||||
listCampaignPrintTemplates,
|
||||
previewCampaignAttachments,
|
||||
saveCampaignContentLibraryItem,
|
||||
type CampaignAttachmentPreviewRule,
|
||||
type CampaignContentLibraryItem,
|
||||
type CampaignContentLibraryResponse,
|
||||
type CampaignContentLibraryTarget,
|
||||
type CampaignPrintTemplate
|
||||
} from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
@@ -29,6 +35,7 @@ import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplat
|
||||
type TemplateBodyMode = "text" | "html" | "both";
|
||||
type BodyEditorMode = "text" | "html";
|
||||
type EditorTarget = "subject" | "text" | "html";
|
||||
type ContentLibrarySaveKind = "fragment" | "campaign_part";
|
||||
|
||||
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
@@ -44,6 +51,19 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
|
||||
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
|
||||
const [printTemplatesError, setPrintTemplatesError] = useState("");
|
||||
const [contentLibraryOpen, setContentLibraryOpen] = useState(false);
|
||||
const [contentLibraryQuery, setContentLibraryQuery] = useState("");
|
||||
const [contentLibrary, setContentLibrary] = useState<CampaignContentLibraryResponse | null>(null);
|
||||
const [contentLibraryLoading, setContentLibraryLoading] = useState(false);
|
||||
const [contentLibraryError, setContentLibraryError] = useState("");
|
||||
const [contentSaveOpen, setContentSaveOpen] = useState(false);
|
||||
const [contentSaveBusy, setContentSaveBusy] = useState(false);
|
||||
const [contentSaveName, setContentSaveName] = useState("");
|
||||
const [contentSaveDescription, setContentSaveDescription] = useState("");
|
||||
const [contentSaveKind, setContentSaveKind] = useState<ContentLibrarySaveKind>("fragment");
|
||||
const [contentSaveTarget, setContentSaveTarget] = useState<CampaignContentLibraryTarget>("text");
|
||||
const [contentSaveVisibility, setContentSaveVisibility] = useState<"personal" | "tenant">("personal");
|
||||
const [contentLibraryNotice, setContentLibraryNotice] = useState("");
|
||||
const subjectRef = useRef<HTMLInputElement | null>(null);
|
||||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
|
||||
@@ -68,6 +88,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
const printConfig = asRecord(delivery.print);
|
||||
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
|
||||
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
|
||||
const contentSaveSelectedValue = getText(template, contentSaveTarget);
|
||||
const contentSaveHasBody = Boolean(getText(template, "text").trim() || getText(template, "html").trim());
|
||||
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
|
||||
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
|
||||
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
|
||||
@@ -179,6 +201,32 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
return () => { cancelled = true; };
|
||||
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentLibraryOpen) return;
|
||||
let cancelled = false;
|
||||
setContentLibraryLoading(true);
|
||||
setContentLibraryError("");
|
||||
const handle = window.setTimeout(() => {
|
||||
void listCampaignContentLibrary(settings, campaignId, contentLibraryQuery)
|
||||
.then((response) => {
|
||||
if (!cancelled) setContentLibrary(response);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setContentLibrary(null);
|
||||
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setContentLibraryLoading(false);
|
||||
});
|
||||
}, 180);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [campaignId, contentLibraryOpen, contentLibraryQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
|
||||
function patchTemplateText(target: EditorTarget, value: string) {
|
||||
patch(["template", target], value);
|
||||
@@ -280,6 +328,87 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
|
||||
function openContentSaveDialog() {
|
||||
setContentSaveName("");
|
||||
setContentSaveDescription("");
|
||||
setContentSaveKind("fragment");
|
||||
setContentSaveTarget(activeEditor === "subject" ? "subject" : visibleBodyEditor);
|
||||
setContentSaveVisibility("personal");
|
||||
setContentLibraryError("");
|
||||
setContentSaveOpen(true);
|
||||
}
|
||||
|
||||
function insertContentFragment(target: CampaignContentLibraryTarget, value: string) {
|
||||
if (locked || !value) return;
|
||||
if (target === "html") {
|
||||
const inserted = htmlRef.current?.insertText(value) ?? false;
|
||||
if (!inserted) patchTemplateText("html", `${getText(template, "html")}${value}`);
|
||||
setActiveBodyEditor("html");
|
||||
setActiveEditor("html");
|
||||
setContentLibraryOpen(false);
|
||||
return;
|
||||
}
|
||||
const element = target === "subject" ? subjectRef.current : textRef.current;
|
||||
const currentText = getText(template, target);
|
||||
const start = element?.selectionStart ?? currentText.length;
|
||||
const end = element?.selectionEnd ?? currentText.length;
|
||||
patchTemplateText(target, `${currentText.slice(0, start)}${value}${currentText.slice(end)}`);
|
||||
window.requestAnimationFrame(() => {
|
||||
element?.focus();
|
||||
const cursor = start + value.length;
|
||||
element?.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
if (target === "text") setActiveBodyEditor("text");
|
||||
setActiveEditor(target);
|
||||
setContentLibraryOpen(false);
|
||||
}
|
||||
|
||||
function applyCampaignPart(item: CampaignContentLibraryItem) {
|
||||
if (locked) return;
|
||||
setDraft((current) => {
|
||||
const next = cloneJson(current ?? {});
|
||||
next.template = {
|
||||
...asRecord(next.template),
|
||||
subject: item.subject ?? "",
|
||||
text: item.text ?? "",
|
||||
html: item.html ?? "",
|
||||
body_mode: item.body_mode
|
||||
};
|
||||
return next;
|
||||
});
|
||||
markDirty();
|
||||
setActiveBodyEditor(item.body_mode === "html" ? "html" : "text");
|
||||
setActiveEditor(item.body_mode === "html" ? "html" : "text");
|
||||
setContentLibraryOpen(false);
|
||||
}
|
||||
|
||||
async function saveReusableContent() {
|
||||
if (contentSaveBusy || !contentSaveName.trim()) return;
|
||||
setContentSaveBusy(true);
|
||||
setContentLibraryError("");
|
||||
try {
|
||||
const result = await saveCampaignContentLibraryItem(settings, campaignId, {
|
||||
name: contentSaveName.trim(),
|
||||
description: contentSaveDescription.trim() || null,
|
||||
kind: contentSaveKind,
|
||||
target: contentSaveKind === "fragment" ? contentSaveTarget : null,
|
||||
subject: getText(template, "subject"),
|
||||
text: getText(template, "text"),
|
||||
html: getText(template, "html"),
|
||||
body_mode: templateBodyMode,
|
||||
locale: "de",
|
||||
visibility: contentSaveVisibility
|
||||
});
|
||||
setContentSaveOpen(false);
|
||||
setContentLibraryNotice(`${result.template.name} was saved as an unpublished Templates draft.`);
|
||||
setContentLibrary(null);
|
||||
} catch (reason) {
|
||||
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setContentSaveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
@@ -289,7 +418,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
@@ -297,6 +426,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{contentLibraryNotice && <DismissibleAlert tone="success" resetKey={contentLibraryNotice} floating>{contentLibraryNotice}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
@@ -371,8 +501,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
</div>
|
||||
}
|
||||
<div className="button-row template-editor-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||
<Button onClick={() => setContentLibraryOpen(true)} disabled={locked}>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||
<Button onClick={openContentSaveDialog} disabled={locked}>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -494,6 +624,154 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
}
|
||||
|
||||
<Dialog
|
||||
open={contentLibraryOpen}
|
||||
title="Reusable content"
|
||||
className="campaign-content-library-dialog"
|
||||
helpContextId="campaign.template.content-library"
|
||||
onClose={() => setContentLibraryOpen(false)}
|
||||
footer={<>
|
||||
<Button onClick={() => window.location.assign("/templates")}>Manage Templates</Button>
|
||||
<Button variant="primary" onClick={() => setContentLibraryOpen(false)}>Close</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="campaign-content-library">
|
||||
<FormField label="Search library" help="Searches content published or visible to your Templates scope.">
|
||||
<input
|
||||
value={contentLibraryQuery}
|
||||
onChange={(event) => setContentLibraryQuery(event.target.value)}
|
||||
placeholder="Name or description"
|
||||
autoFocus
|
||||
/>
|
||||
</FormField>
|
||||
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
|
||||
<LoadingFrame loading={contentLibraryLoading} label="Loading reusable content">
|
||||
<div className="campaign-content-library-list">
|
||||
{!contentLibraryError && contentLibrary && !contentLibrary.available && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>{contentLibrary.reason || "Enable Templates to use reusable content."}</DismissibleAlert>
|
||||
)}
|
||||
{!contentLibraryError && contentLibrary?.available && contentLibrary.items.length === 0 && (
|
||||
<p className="muted">No reusable Campaign content matches this search.</p>
|
||||
)}
|
||||
{contentLibrary?.items.map((item) => (
|
||||
<div className="campaign-content-library-item" key={`${item.id}:${item.revision_id}`}>
|
||||
<div className="campaign-content-library-item-copy">
|
||||
<div className="campaign-content-library-item-title">
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.published ? `Published r${item.revision}` : `Draft r${item.revision}`}</span>
|
||||
</div>
|
||||
{item.description && <p>{item.description}</p>}
|
||||
<small>{item.kind === "fragment" ? "Content fragment" : "Complete campaign part"} · {item.scope_type} · {item.locale || "unspecified locale"}</small>
|
||||
</div>
|
||||
<div className="button-row campaign-content-library-item-actions">
|
||||
{item.kind === "campaign_part" ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={locked}
|
||||
onClick={() => applyCampaignPart(item)}
|
||||
title="Replaces the current subject and body fields in this draft"
|
||||
>Apply part</Button>
|
||||
) : item.targets.map((target) => {
|
||||
const value = target === "html" ? item.html : item.text;
|
||||
return (
|
||||
<Button
|
||||
key={target}
|
||||
disabled={locked || !value}
|
||||
onClick={() => value && insertContentFragment(target, value)}
|
||||
>Insert in {target}</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={contentSaveOpen}
|
||||
title="Save reusable content"
|
||||
className="campaign-content-save-dialog"
|
||||
helpContextId="campaign.template.content-library"
|
||||
closeDisabled={contentSaveBusy}
|
||||
onClose={() => setContentSaveOpen(false)}
|
||||
footer={<>
|
||||
<Button onClick={() => setContentSaveOpen(false)} disabled={contentSaveBusy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveReusableContent()}
|
||||
disabled={
|
||||
contentSaveBusy ||
|
||||
!contentSaveName.trim() ||
|
||||
(contentSaveKind === "fragment" ? !contentSaveSelectedValue.trim() : !contentSaveHasBody)
|
||||
}
|
||||
>{contentSaveBusy ? "Saving..." : "Save draft"}</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="campaign-content-save-form">
|
||||
<DismissibleAlert tone="info" compact dismissible={false}>
|
||||
Saving creates an unpublished Templates draft. Publication and later revisions remain governed in Templates.
|
||||
</DismissibleAlert>
|
||||
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
|
||||
<div className="campaign-content-save-identity">
|
||||
<FormField label="Name">
|
||||
<input value={contentSaveName} onChange={(event) => setContentSaveName(event.target.value)} autoFocus />
|
||||
</FormField>
|
||||
<FormField label="Visibility" help="Personal drafts are visible to you; tenant drafts are available to authorized template users.">
|
||||
<SegmentedControl
|
||||
ariaLabel="Template visibility"
|
||||
value={contentSaveVisibility}
|
||||
onChange={setContentSaveVisibility}
|
||||
size="content"
|
||||
width="inline"
|
||||
options={[
|
||||
{ id: "personal", label: "Personal" },
|
||||
{ id: "tenant", label: "Tenant" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Description">
|
||||
<textarea rows={3} value={contentSaveDescription} onChange={(event) => setContentSaveDescription(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Content kind">
|
||||
<SegmentedControl
|
||||
ariaLabel="Reusable content kind"
|
||||
value={contentSaveKind}
|
||||
onChange={setContentSaveKind}
|
||||
size="content"
|
||||
width="inline"
|
||||
options={[
|
||||
{ id: "fragment", label: "Fragment" },
|
||||
{ id: "campaign_part", label: "Complete part" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
{contentSaveKind === "fragment" && (
|
||||
<FormField label="Source field" help="The selected current field becomes the reusable fragment.">
|
||||
<SegmentedControl
|
||||
ariaLabel="Fragment source field"
|
||||
value={contentSaveTarget}
|
||||
onChange={setContentSaveTarget}
|
||||
size="content"
|
||||
width="inline"
|
||||
options={[
|
||||
{ id: "subject", label: "Subject" },
|
||||
{ id: "text", label: "Text" },
|
||||
{ id: "html", label: "HTML" }
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<p className="muted small-note">
|
||||
{contentSaveKind === "fragment"
|
||||
? `${contentSaveSelectedValue.length.toLocaleString()} characters from ${contentSaveTarget}.`
|
||||
: "Subject, text, HTML, and body mode are stored as one reusable Campaign part."}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="template"
|
||||
|
||||
@@ -29,6 +29,12 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
|
||||
send_without_attachments: true,
|
||||
send_without_attachments_behavior: "continue",
|
||||
global: [],
|
||||
residual_files: {
|
||||
mode: "none",
|
||||
recipient: null,
|
||||
subject: "Unassigned files in campaign {{local:campaign_name}}",
|
||||
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||
},
|
||||
missing_behavior: "warn",
|
||||
ambiguous_behavior: "ask",
|
||||
...sourceAttachments
|
||||
|
||||
Reference in New Issue
Block a user