feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -3,10 +3,12 @@ import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createRecipientImportMappingProfile,
|
||||
getCampaignPostboxCatalog,
|
||||
listCampaignRecipientAddressSources,
|
||||
listRecipientImportMappingProfiles,
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
updateRecipientImportMappingProfile,
|
||||
type CampaignPostboxCatalog,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot,
|
||||
type RecipientImportMappingProfilePayload } from
|
||||
@@ -30,6 +32,9 @@ import { getBool } from "./utils/draftEditor";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||
import PostboxTargetsDialog, {
|
||||
normalizePostboxTargets
|
||||
} from "./components/PostboxTargetsDialog";
|
||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import {
|
||||
@@ -105,6 +110,7 @@ const recipientAddressOverlayColumns: EntryAddressColumn[] = [
|
||||
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
|
||||
@@ -115,6 +121,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
||||
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
||||
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
||||
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
|
||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
@@ -192,12 +205,53 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postboxModuleInstalled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getCampaignPostboxCatalog(settings, campaignId)
|
||||
.then((catalog) => {
|
||||
if (!cancelled) setPostboxCatalog(catalog);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
campaignId,
|
||||
postboxModuleInstalled,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setRecipientAddressEditorIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (inlineEntries.length === 0) return null;
|
||||
return Math.max(0, Math.min(current, inlineEntries.length - 1));
|
||||
});
|
||||
setPostboxTargetEditorIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (inlineEntries.length === 0) return null;
|
||||
return Math.max(0, Math.min(current, inlineEntries.length - 1));
|
||||
});
|
||||
}, [inlineEntries.length]);
|
||||
|
||||
|
||||
@@ -251,6 +305,19 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
setRecipientAddressEditorIndex(null);
|
||||
}
|
||||
|
||||
function saveEntryPostboxTargets(
|
||||
index: number,
|
||||
targets: ReturnType<typeof normalizePostboxTargets>,
|
||||
merge: boolean
|
||||
) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
postbox_targets: targets,
|
||||
merge_postbox_targets: merge
|
||||
}));
|
||||
setPostboxTargetEditorIndex(null);
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
@@ -449,12 +516,15 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
draft: displayDraft,
|
||||
locked,
|
||||
filesModuleInstalled,
|
||||
postboxModuleInstalled,
|
||||
postboxCatalog,
|
||||
entries: inlineEntries,
|
||||
fieldDefinitions,
|
||||
individualAttachmentBasePaths,
|
||||
zipConfig,
|
||||
translateText,
|
||||
openAddressEditor: setRecipientAddressEditorIndex,
|
||||
openPostboxTargetEditor: setPostboxTargetEditorIndex,
|
||||
updateEntry,
|
||||
updateEntryAttachments,
|
||||
updateEntryField,
|
||||
@@ -512,6 +582,20 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
|
||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
||||
|
||||
}
|
||||
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
|
||||
<PostboxTargetsDialog
|
||||
open
|
||||
title={`Recipient ${postboxTargetEditorIndex + 1} - Postbox targets`}
|
||||
catalog={postboxCatalog}
|
||||
fields={fieldDefinitions}
|
||||
targets={normalizePostboxTargets(inlineEntries[postboxTargetEditorIndex].postbox_targets)}
|
||||
merge={inlineEntries[postboxTargetEditorIndex].merge_postbox_targets !== false}
|
||||
showMerge
|
||||
locked={locked}
|
||||
onSave={(targets, merge) => saveEntryPostboxTargets(postboxTargetEditorIndex, targets, merge)}
|
||||
onClose={() => setPostboxTargetEditorIndex(null)} />
|
||||
|
||||
}
|
||||
{headerAddressEditor &&
|
||||
<HeaderAddressEditorDialog
|
||||
@@ -2012,12 +2096,15 @@ type RecipientProfileColumnContext = {
|
||||
draft: Record<string, unknown>;
|
||||
locked: boolean;
|
||||
filesModuleInstalled: boolean;
|
||||
postboxModuleInstalled: boolean;
|
||||
postboxCatalog: CampaignPostboxCatalog;
|
||||
entries: Record<string, unknown>[];
|
||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||
zipConfig: AttachmentZipCollection;
|
||||
translateText: (value: string) => string;
|
||||
openAddressEditor: (index: number) => void;
|
||||
openPostboxTargetEditor: (index: number) => void;
|
||||
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
||||
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
|
||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||
@@ -2026,7 +2113,7 @@ type RecipientProfileColumnContext = {
|
||||
removeEntry: (index: number) => void;
|
||||
};
|
||||
|
||||
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{
|
||||
id: "number",
|
||||
@@ -2075,6 +2162,45 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod
|
||||
value: recipientAddressFilterValue
|
||||
},
|
||||
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
|
||||
...(postboxModuleInstalled ? [{
|
||||
id: "delivery",
|
||||
header: "Delivery",
|
||||
width: "minmax(260px, 0.9fr)",
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
render: (entry, index) => {
|
||||
const targets = normalizePostboxTargets(entry.postbox_targets);
|
||||
return (
|
||||
<div className="campaign-recipient-delivery-cell">
|
||||
<select
|
||||
value={typeof entry.channel_policy === "string" ? entry.channel_policy : ""}
|
||||
disabled={locked}
|
||||
aria-label={`Recipient ${index + 1} delivery policy`}
|
||||
onChange={(event) => updateEntry(index, (current) => {
|
||||
const next = { ...current };
|
||||
if (event.target.value) next.channel_policy = event.target.value;
|
||||
else delete next.channel_policy;
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
<option value="">Campaign default</option>
|
||||
<option value="mail">Mail</option>
|
||||
<option value="postbox">Postbox</option>
|
||||
<option value="mail_and_postbox">Mail and Postbox</option>
|
||||
<option value="mail_then_postbox">Mail, then Postbox fallback</option>
|
||||
<option value="postbox_then_mail">Postbox, then Mail fallback</option>
|
||||
</select>
|
||||
<Button
|
||||
disabled={locked || !postboxCatalog.available}
|
||||
onClick={() => openPostboxTargetEditor(index)}
|
||||
>
|
||||
Postboxes ({targets.length})
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")}`
|
||||
} as DataGridColumn<Record<string, unknown>>] : []),
|
||||
...(individualAttachmentBasePaths.length > 0 ? [{
|
||||
id: "attachments",
|
||||
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
||||
|
||||
Reference in New Issue
Block a user