Refactor campaign recipient and review presentation

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent 5f7503598c
commit e689fdf495
30 changed files with 2175 additions and 1977 deletions
@@ -0,0 +1,221 @@
import { Pencil } from "lucide-react";
import type { ApiSettings } from "../../../types";
import type { CampaignPostboxCatalog } from "../../../api/campaigns";
import {
Button,
DataGridRowActions,
ToggleSwitch,
i18nMessage,
type DataGridColumn
} from "@govoplan/core-webui";
import AttachmentRulesOverlay from "../components/AttachmentRulesOverlay";
import FieldValueInput from "../components/FieldValueInput";
import {
normalizePostboxTargets
} from "../components/PostboxTargetsDialog";
import { buildTemplatePreviewContext } from "../utils/templatePlaceholders";
import {
getIndividualAttachmentBasePaths,
normalizeAttachmentRules,
type AttachmentRule,
type AttachmentZipCollection
} from "../utils/attachments";
import { getDraftFields } from "../utils/fieldDefinitions";
import { asRecord } from "../utils/campaignView";
import {
getEntryAddresses,
hiddenRecipientAddressMatch,
recipientAddressFilterValue,
recipientAddressSummary
} from "./RecipientAddressEditor";
export type RecipientProfileColumnContext = {
settings: ApiSettings;
campaignId: string;
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;
addressFilter: string;
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;
addRecipient: (afterIndex?: number) => void;
moveEntry: (index: number, targetIndex: number) => void;
removeEntry: (index: number) => void;
};
export function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{
id: "number",
header: "#",
width: 72,
sortable: true,
filterType: "integer",
sticky: "start",
render: (_entry, index) =>
<span className="recipient-data-number">
{index + 1}
</span>,
value: (_entry, index) => index + 1
},
{
id: "recipients",
header: "Recipient(s)",
width: "minmax(320px, 1.4fr)",
maxWidth: 640,
resizable: true,
filterable: true,
render: (entry, index) => {
const summary = recipientAddressSummary(entry, translateText);
const hiddenMatch = hiddenRecipientAddressMatch(entry, addressFilter);
const content = (
<>
<span className="recipient-address-editor-main">
<span className={`recipient-data-address ${summary.empty ? "is-empty" : ""}`}>{summary.primary}</span>
{summary.badges.map((badge) =>
<span className="recipient-extra-bubble" key={badge}>{badge}</span>
)}
{hiddenMatch &&
<span className="recipient-hidden-address-match">
Matched in {hiddenMatch.label}: {hiddenMatch.address}
</span>
}
</span>
{!locked && <Pencil aria-hidden="true" />}
</>
);
if (locked) {
return <div className="recipient-address-editor-trigger is-readonly">{content}</div>;
}
return (
<button
type="button"
className="recipient-address-editor-trigger"
onClick={() => openAddressEditor(index)}>
{content}
</button>);
},
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)",
maxWidth: 480,
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",
width: 180,
filterable: true,
render: (entry, index) => {
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<AttachmentRulesOverlay
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
rules={attachments}
settings={settings}
campaignId={campaignId}
disabled={locked}
buttonLabel={`entries: ${attachments.length}`}
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={buildTemplatePreviewContext(draft, entry)}
onChange={(rules) => updateEntryAttachments(index, rules)} />);
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
}] : []),
...fieldDefinitions.filter((field) => field.can_override !== false).map((field): DataGridColumn<Record<string, unknown>> => ({
id: `field-${field.name}`,
header: field.label || field.name,
width: 190,
minWidth: 160,
maxWidth: 360,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
render: (entry, index) => {
const fields = asRecord(entry.fields);
return (
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked}
onChange={(value) => updateEntryField(index, field.name, value)} />);
},
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
})),
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_entry, index) =>
<DataGridRowActions
disabled={locked}
onAddBelow={() => addRecipient(index)}
onRemove={() => removeEntry(index)}
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
addLabel="i18n:govoplan-campaign.add_recipient_below.52fb47d8"
removeLabel="i18n:govoplan-campaign.remove_recipient.d1bc9f53"
moveUpLabel="i18n:govoplan-campaign.move_recipient_up.90c6bccc"
moveDownLabel="i18n:govoplan-campaign.move_recipient_down.ead5466c" />
}];
}