Implement governed hybrid campaign delivery

This commit is contained in:
2026-08-02 13:58:37 +02:00
parent b38597f2be
commit 7733265cc8
40 changed files with 2531 additions and 122 deletions
@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
import {
listCampaignPrintTemplates,
previewCampaignAttachments,
type CampaignAttachmentPreviewRule,
type CampaignPrintTemplate
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui";
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
@@ -35,6 +40,10 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
const [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]);
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
const [printTemplatesAvailable, setPrintTemplatesAvailable] = useState(false);
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
const [printTemplatesError, setPrintTemplatesError] = useState("");
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
@@ -55,6 +64,9 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const delivery = asRecord(displayDraft.delivery);
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 visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
@@ -145,6 +157,28 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
};
}, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]);
useEffect(() => {
let cancelled = false;
setPrintTemplatesLoading(true);
setPrintTemplatesError("");
void listCampaignPrintTemplates(settings, campaignId)
.then((response) => {
if (cancelled) return;
setPrintTemplatesAvailable(response.available);
setPrintTemplates(response.templates);
})
.catch((reason: unknown) => {
if (cancelled) return;
setPrintTemplatesAvailable(false);
setPrintTemplates([]);
setPrintTemplatesError(reason instanceof Error ? reason.message : String(reason));
})
.finally(() => {
if (!cancelled) setPrintTemplatesLoading(false);
});
return () => { cancelled = true; };
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
@@ -159,6 +193,22 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
}
}
function patchPrintConfig(values: Record<string, unknown>) {
if (locked) return;
patch(["delivery", "print"], { ...printConfig, ...values });
}
function selectPrintTemplate(templateId: string) {
const selected = printTemplates.find((item) => item.id === templateId) ?? null;
const profile = selected?.revision?.output_profiles[0] ?? null;
patchPrintConfig({
template_id: selected?.id ?? null,
template_revision: selected?.revision?.revision ?? selected?.current_revision ?? null,
output_format: profile?.output_format ?? getText(printConfig, "output_format", "html"),
profile_id: profile?.id ?? null
});
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
@@ -328,6 +378,64 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
</Card>
<div className="template-side-stack">
<Card title="Printable output">
<LoadingFrame loading={printTemplatesLoading} label="Loading printable templates">
<div className="form-grid">
{printTemplatesError && <DismissibleAlert tone="danger" compact resetKey={printTemplatesError}>{printTemplatesError}</DismissibleAlert>}
{!printTemplatesError && !printTemplatesAvailable && (
<DismissibleAlert tone="info" compact dismissible={false}>
Printable delivery becomes available when the Templates module is enabled. Mail-only Campaigns remain unaffected.
</DismissibleAlert>
)}
{printTemplatesAvailable && (
<>
<FormField label="Print template" help="Used for recipients whose frozen route is postal or internal mail, including safe fallbacks.">
<select
value={getText(printConfig, "template_id")}
disabled={locked}
onChange={(event) => selectPrintTemplate(event.target.value)}
>
<option value="">Select a published template</option>
{printTemplates.map((item) => (
<option key={item.id} value={item.id} disabled={!item.published_revision_id}>
{item.name} · {item.template_type} · r{item.revision?.revision ?? item.current_revision}
</option>
))}
</select>
</FormField>
{selectedPrintTemplate && (
<p className="muted small-note">
{selectedPrintTemplate.description || `${selectedPrintTemplate.template_type} template`}
{selectedPrintTemplate.revision?.required_fields.length
? ` · Required fields: ${selectedPrintTemplate.revision.required_fields.map((field) => field.label || field.path).join(", ")}`
: " · No additional fields required"}
</p>
)}
<FormField label="Output format">
<SegmentedControl
ariaLabel="Printable output format"
value={getText(printConfig, "output_format", "html") as "html" | "text"}
disabled={locked || !selectedPrintTemplate}
size="content"
width="inline"
onChange={(outputFormat) => patchPrintConfig({ output_format: outputFormat })}
options={[
{ id: "html", label: "HTML" },
{ id: "text", label: "Text" }
]}
/>
</FormField>
<ToggleSwitch
label="Store generated output in Files"
checked={getBool(printConfig, "persist_to_files", true)}
disabled={locked || !selectedPrintTemplate}
onChange={(checked) => patchPrintConfig({ persist_to_files: checked })}
/>
</>
)}
</div>
</LoadingFrame>
</Card>
<Card title="i18n:govoplan-campaign.fields.e8b68527">
{invalidNamespacePlaceholders.length > 0 &&
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>