feat(campaign-ui): configure attachment delivery behavior

This commit is contained in:
2026-07-20 20:08:53 +02:00
parent 0a6064ec62
commit c71fa3dc64
3 changed files with 45 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
@@ -40,7 +41,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null); const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
version, version,
@@ -238,7 +239,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>} {filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button> <Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button> <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
</div> </div>
</div> </div>
@@ -249,6 +250,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50"> <LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<> <>
<div className="metric-grid attachment-statistics-grid">
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" />
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.upload_support.1c54931c" value={managedFilesAvailable ? "Files" : "Manual"} tone={managedFilesAvailable ? "good" : filesModuleInstalled ? "warning" : "neutral"} />
</div>
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce"> <Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<div className="admin-table-surface attachment-sources-table-surface"> <div className="admin-table-surface attachment-sources-table-surface">
<DataGrid <DataGrid
@@ -300,7 +308,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
{zipArchiveNameValidation.message && {zipArchiveNameValidation.message &&
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert> <DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
} }
<p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
</Card> </Card>
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible> <Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
@@ -319,20 +326,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
</Card> </Card>
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
<dl className="detail-list">
<div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
<div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
<div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
<div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</dd></div>
</dl>
<p className="muted small-note">{managedFilesAvailable ?
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
filesModuleInstalled ?
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
</Card>
</> </>
</LoadingFrame> </LoadingFrame>
@@ -450,6 +443,19 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />, render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
value: (archive) => archive.password_enabled ? "protected" : "none" value: (archive) => archive.password_enabled ? "protected" : "none"
}, },
{
id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] },
render: (archive, index) =>
<ToggleSwitch
label="Win-compatible"
checked={archive.method === "zip_standard"}
disabled={disabled}
help="Win-compatible ZIP uses the legacy ZipCrypto format so password-protected archives can be opened with Windows Explorer. Use AES when recipients can use 7-Zip, NanaZip, WinRAR, or another AES-capable ZIP tool."
onChange={(checked) => patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />,
value: (archive) => archive.method
},
{ {
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true, id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] }, columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },

View File

@@ -38,7 +38,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
const version = data.currentVersion; const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
settings, settings,
campaignId, campaignId,
version, version,
@@ -71,6 +71,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
markDirty(); markDirty();
} }
function patchSendWithoutAttachmentsBehavior(value: string) {
patch(["attachments", "send_without_attachments_behavior"], value);
patch(["attachments", "send_without_attachments"], value !== "block");
}
return ( return (
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
@@ -79,7 +84,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button> <Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button> <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
</div> </div>
</div> </div>
@@ -208,8 +213,8 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
effectiveClassName="campaign-policy-effective-note" effectiveClassName="campaign-policy-effective-note"
label="i18n:govoplan-campaign.send_without_attachments.ead6d030" label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc" note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
control={<ToggleSwitch label="i18n:govoplan-campaign.allowed.77c7b490" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />} control={<PolicySelectControl value={sendWithoutAttachmentsBehavior(attachments)} disabled={locked} onChange={patchSendWithoutAttachmentsBehavior} />}
effective={getBool(attachments, "send_without_attachments", true) ? "i18n:govoplan-campaign.messages_may_be_sent_when_attachment_rules_allow.e6bf1aed" : "i18n:govoplan-campaign.missing_attachment_coverage_blocks_sending.05ff02a1"} /> effective={behaviorSummary(sendWithoutAttachmentsBehavior(attachments))} />
</PolicyTable> </PolicyTable>
</Card> </Card>
@@ -261,3 +266,9 @@ function behaviorSummary(value: string): string {
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54"; if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3"; return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
} }
function sendWithoutAttachmentsBehavior(attachments: Record<string, unknown>): string {
const configured = getText(attachments, "send_without_attachments_behavior");
if (behaviorOptions.includes(configured)) return configured;
return getBool(attachments, "send_without_attachments", true) ? "continue" : "block";
}

View File

@@ -22,15 +22,21 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
raw.server = isRecord(raw.server) ? raw.server : {}; raw.server = isRecord(raw.server) ? raw.server : {};
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {}; raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" }; raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
const sourceAttachments = asRecord(raw.attachments);
raw.attachments = { raw.attachments = {
base_path: ".", base_path: ".",
allow_individual: false, allow_individual: false,
send_without_attachments: true, send_without_attachments: true,
send_without_attachments_behavior: "continue",
global: [], global: [],
missing_behavior: "ask", missing_behavior: "ask",
ambiguous_behavior: "ask", ambiguous_behavior: "ask",
...asRecord(raw.attachments) ...sourceAttachments
}; };
const normalizedAttachments = asRecord(raw.attachments);
if (sourceAttachments.send_without_attachments_behavior === undefined) {
normalizedAttachments.send_without_attachments_behavior = getBool(normalizedAttachments, "send_without_attachments", true) ? "continue" : "block";
}
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] }; raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
raw.validation_policy = { raw.validation_policy = {
unsent_attachment_files: "warn", unsent_attachment_files: "warn",