feat: add campaign copying scheduling and residual handling

This commit is contained in:
2026-08-07 14:54:04 +02:00
parent 696f8f6385
commit c2efd6b7bd
35 changed files with 3359 additions and 39 deletions
@@ -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