diff --git a/webui/src/features/campaigns/CampaignWorkspace.tsx b/webui/src/features/campaigns/CampaignWorkspace.tsx index bc8baef..0a6d6fa 100644 --- a/webui/src/features/campaigns/CampaignWorkspace.tsx +++ b/webui/src/features/campaigns/CampaignWorkspace.tsx @@ -7,7 +7,6 @@ import CampaignOverviewPage from "./CampaignOverviewPage"; import CampaignFieldsPage from "./CampaignFieldsPage"; import GlobalSettingsPage from "./GlobalSettingsPage"; import RecipientDataPage from "./RecipientDataPage"; -import RecipientDetailsPage from "./RecipientDetailsPage"; import TemplateDataPage from "./TemplateDataPage"; import AttachmentsDataPage from "./AttachmentsDataPage"; import MailSettingsPage from "./MailSettingsPage"; @@ -26,7 +25,7 @@ const sectionPaths: Record = { policies: "policies", fields: "fields", recipients: "recipients", - "recipient-data": "recipient-data", + "recipient-data": "recipients", template: "template", files: "files", "mail-settings": "mail-settings", @@ -85,7 +84,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -127,7 +126,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection { if (section === "policies" || section === "policy") return "policies"; if (section === "fields") return "fields"; if (section === "recipients") return "recipients"; - if (section === "recipient-data" || section === "recipient-details") return "recipient-data"; + if (section === "recipient-data" || section === "recipient-details") return "recipients"; if (section === "template") return "template"; if (section === "files" || section === "attachments") return "files"; if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings"; diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index 51ac482..1a2982d 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -1,13 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react"; +import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react"; import type { ApiSettings } from "../../types"; import { createRecipientImportMappingProfile, listCampaignRecipientAddressSources, listRecipientImportMappingProfiles, - lookupCampaignAddresses, snapshotCampaignRecipientAddressSource, updateRecipientImportMappingProfile, - type CampaignAddressLookupCandidate, type CampaignRecipientAddressSource, type CampaignRecipientAddressSourceSnapshot, type RecipientImportMappingProfilePayload } from @@ -16,10 +15,9 @@ import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { FileDropZone } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; -import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui"; +import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui"; import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold"; import { ToggleSwitch } from "@govoplan/core-webui"; -import { EmailAddressInput } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { SegmentedControl } from "@govoplan/core-webui"; @@ -29,7 +27,10 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { getBool } from "./utils/draftEditor"; import { getDraftFields } from "./utils/fieldDefinitions"; -import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments"; +import FieldValueInput from "./components/FieldValueInput"; +import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay"; +import { buildTemplatePreviewContext } from "./utils/templatePlaceholders"; +import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments"; import { applyRecipientMappingProfile, buildImportTable, @@ -65,8 +66,8 @@ import { "./utils/fileLinking"; import { addressesFromValue, - collectCampaignAddressSuggestions, dedupeAddresses, + parseMailboxAddressText, type MailboxAddress } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui"; @@ -77,10 +78,15 @@ const recipientHeaderRows = [ const; type RecipientAddressKey = "to" | "cc" | "bcc"; +type AddressFieldKey = RecipientAddressKey | "from" | "reply_to"; type AddressSourceFilter = "all" | "books" | "lists"; +type HeaderAddressEditorState = { + title: string; + columns: EntryAddressColumn[]; +} | null; type EntryAddressColumn = { - key: RecipientAddressKey | "from" | "reply_to"; + key: AddressFieldKey; label: string; allowMultiple: boolean; addLabel: string; @@ -88,8 +94,16 @@ type EntryAddressColumn = { mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to"; }; +const recipientAddressOverlayColumns: EntryAddressColumn[] = [ +{ key: "from", label: "i18n:govoplan-campaign.from.3f66052a", allowMultiple: false, addLabel: "i18n:govoplan-campaign.set_from.11fe7396", emptyText: "i18n:govoplan-campaign.uses_global_from.4cf8e6ce" }, +{ key: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_reply_to.84b195f4", emptyText: "i18n:govoplan-campaign.uses_global_reply_to.72c8d931", mergeKey: "merge_reply_to" }, +{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_to.768a148b", emptyText: "i18n:govoplan-campaign.no_recipient_address.1301a488", mergeKey: "merge_to" }, +{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_cc.5463a081", mergeKey: "merge_cc" }, +{ key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_bcc.48cade21", mergeKey: "merge_bcc" }]; + export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { const { translateText } = usePlatformLanguage(); + const filesModuleInstalled = usePlatformModuleInstalled("files"); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [importOpen, setImportOpen] = useState(false); const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false); @@ -97,15 +111,14 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false); const [addressSourcesLoading, setAddressSourcesLoading] = useState(false); const [addressSources, setAddressSources] = useState([]); - const [addressLookupQuery, setAddressLookupQuery] = useState(""); - const [addressLookupSuggestions, setAddressLookupSuggestions] = useState([]); - const [addressLookupCache, setAddressLookupCache] = useState>({}); const [recipientProfilesPage, setRecipientProfilesPage] = useState(1); const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10); + const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState(null); + const [headerAddressEditor, setHeaderAddressEditor] = useState(null); const version = data.currentVersion; 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, campaignId, version, @@ -123,17 +136,11 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A const inlineEntries = asArray(entries.inline).map(asRecord); const source = asRecord(entries.source); const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]); + const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); + const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]); const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]); const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]); - const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]); - const cachedAddressSuggestions = useMemo( - () => dedupeAddresses(Object.values(addressLookupCache).flat()), - [addressLookupCache] - ); - const combinedAddressSuggestions = useMemo( - () => dedupeAddresses([...addressSuggestions, ...cachedAddressSuggestions, ...addressLookupSuggestions]), - [addressLookupSuggestions, addressSuggestions, cachedAddressSuggestions] - ); + const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); const addressSourceRevisionById = useMemo( () => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])), [addressSources] @@ -162,22 +169,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A cc: addressesFromValue(recipientsSection.cc), bcc: addressesFromValue(recipientsSection.bcc) }; - const entryAddressColumns = useMemo(() => { - const columns: EntryAddressColumn[] = []; - if (getBool(recipientsSection, "allow_individual_from")) { - columns.push({ key: "from", label: "i18n:govoplan-campaign.from.3f66052a", allowMultiple: false, addLabel: "i18n:govoplan-campaign.set_from.11fe7396", emptyText: "i18n:govoplan-campaign.uses_global_from.4cf8e6ce" }); - } - if (getBool(recipientsSection, "allow_individual_reply_to")) { - columns.push({ key: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_reply_to.84b195f4", emptyText: "i18n:govoplan-campaign.uses_global_reply_to.72c8d931", mergeKey: "merge_reply_to" }); - } - if (getBool(recipientsSection, "allow_individual_to")) { - columns.push({ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_to.768a148b", emptyText: "i18n:govoplan-campaign.no_recipient_address.1301a488", mergeKey: "merge_to" }); - } - if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_cc.5463a081", mergeKey: "merge_cc" }); - if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_bcc.48cade21", mergeKey: "merge_bcc" }); - return columns; - }, [recipientsSection]); - + const fromAddressColumn = getAddressColumn("from"); + const replyToAddressColumn = getAddressColumn("reply_to"); useEffect(() => { let cancelled = false; setAddressSourcesLoading(true); @@ -199,53 +192,12 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A }, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); useEffect(() => { - const query = addressLookupQuery.trim(); - if (query.length < 2) { - setAddressLookupSuggestions(addressLookupCache[""] ?? []); - return; - } - const cached = addressLookupCache[query.toLowerCase()]; - if (cached) { - setAddressLookupSuggestions(cached); - return; - } - let cancelled = false; - const timer = window.setTimeout(() => { - void lookupCampaignAddresses(settings, campaignId, query). - then((response) => { - if (cancelled) return; - if (!response.available) { - setAddressLookupSuggestions([]); - return; - } - const nextSuggestions = mailboxAddressesFromLookupCandidates(response.candidates); - setAddressLookupCache((current) => ({ ...current, [query.toLowerCase()]: nextSuggestions })); - setAddressLookupSuggestions(nextSuggestions); - }). - catch(() => { - if (!cancelled) setAddressLookupSuggestions([]); - }); - }, 100); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [addressLookupCache, addressLookupQuery, campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); - - useEffect(() => { - let cancelled = false; - void lookupCampaignAddresses(settings, campaignId, "", 100). - then((response) => { - if (cancelled || !response.available) return; - setAddressLookupCache((current) => ({ ...current, "": mailboxAddressesFromLookupCandidates(response.candidates) })); - }). - catch(() => undefined); - return () => {cancelled = true;}; - }, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); - - const handleAddressSuggestionQueryChange = useCallback((query: string) => { - setAddressLookupQuery(query); - }, []); + setRecipientAddressEditorIndex((current) => { + if (current === null) return null; + if (inlineEntries.length === 0) return null; + return Math.max(0, Math.min(current, inlineEntries.length - 1)); + }); + }, [inlineEntries.length]); function replaceInlineEntries(nextEntries: Record[]) { @@ -254,6 +206,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A function addRecipient(afterIndex = inlineEntries.length - 1) { const nextIndex = inlineEntries.length + 1; + const insertIndex = Math.max(0, Math.min(afterIndex + 1, inlineEntries.length)); const newEntry = { id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`), active: true, @@ -270,6 +223,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A merge_reply_to: true }; replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry)); + setRecipientAddressEditorIndex(insertIndex); } function updateEntry(index: number, updater: (entry: Record) => Record) { @@ -278,18 +232,15 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A } function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) { + updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses)); + } + + function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) { updateEntry(index, (entry) => { - if (key === "from") { - return { ...entry, from: addresses.slice(0, 1) }; - } - const nextEntry = { ...entry, [key]: addresses }; - if (key === "to") { - const address = addresses[0] ?? { name: "", email: "" }; - return { - ...nextEntry, - name: address.name ?? "", - email: address.email - }; + let nextEntry = entry; + for (const group of groups) { + const currentAddresses = getEntryAddresses(nextEntry, group.key); + nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses])); } return nextEntry; }); @@ -303,13 +254,39 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A }); } + function updateEntryField(index: number, field: string, value: unknown) { + updateEntry(index, (entry) => ({ + ...entry, + fields: { + ...asRecord(entry.fields), + [field]: value + } + })); + } + + function updateEntryAttachments(index: number, attachments: AttachmentRule[]) { + updateEntry(index, (entry) => ({ ...entry, attachments })); + } + function removeEntry(index: number) { replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index)); + setRecipientAddressEditorIndex((current) => { + if (current === null) return null; + if (current === index) return null; + return current > index ? current - 1 : current; + }); } function moveEntry(index: number, targetIndex: number) { if (locked || index === targetIndex) return; replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex)); + setRecipientAddressEditorIndex((current) => { + if (current === null) return null; + if (current === index) return targetIndex; + if (index < current && targetIndex >= current) return current - 1; + if (index > current && targetIndex <= current) return current + 1; + return current; + }); } function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) { @@ -327,6 +304,16 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A setAddressSourceImportOpen(false); } + function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) { + patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses); + } + + async function copyHeaderAddresses(columns: EntryAddressColumn[]) { + const text = formatAddressCollectionForClipboard(columns, headerAddressValues(columns, recipientsSection)); + if (!text) return; + await navigator.clipboard?.writeText(text); + } + return ( <> @@ -338,7 +325,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A version={version} versions={data.versions} saveState={saveState} - onReload={reload} + onReload={discardDraft} onSave={() => saveDraft("manual")} dirty={dirty} locked={locked} @@ -351,16 +338,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
- patch(["recipients", "from"], addresses.slice(0, 1))} /> - + disabled={locked} + onEdit={() => { if (!locked) setHeaderAddressEditor({ title: "Default sender", columns: [fromAddressColumn] }); }} + onCopy={() => void copyHeaderAddresses([fromAddressColumn])} />
patch(["recipients", "allow_individual_from"], checked)} /> -
- patch(["recipients", "reply_to"], addresses)} /> - + disabled={locked} + onEdit={() => { if (!locked) setHeaderAddressEditor({ title: "Global Reply-To", columns: [replyToAddressColumn] }); }} + onCopy={() => void copyHeaderAddresses([replyToAddressColumn])} />
patch(["recipients", "allow_individual_reply_to"], checked)} /> -
- +
- {recipientHeaderRows.map((row) => -
- - patch(["recipients", row.key], addresses)} /> - - -
- patch(["recipients", row.toggleKey], checked)} /> - + {recipientHeaderRows.map((row) => { + const column = getAddressColumn(row.key); + return ( +
+ + { if (!locked) setHeaderAddressEditor({ title: row.label, columns: [column] }); }} + onCopy={() => void copyHeaderAddresses([column])} /> + +
+ patch(["recipients", row.toggleKey], checked)} /> +
-
- )} + ); + })}
{(addressSourcesAvailable || addressSourcesLoading) && @@ -463,11 +441,29 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A String(entry.id || index)} emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026" emptyAction={ addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />} - className="recipient-table-wrap recipient-address-table" + className="recipient-table-wrap recipient-address-table recipient-merged-data-table" pagination={{ page: recipientProfilesPage, pageSize: recipientProfilesPageSize, @@ -504,10 +500,369 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A onImport={applyAddressSourceImport} /> } + {recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] && + setRecipientAddressEditorIndex(null)} /> + + } + {headerAddressEditor && + setHeaderAddressEditor(null)} /> + + } ); } +type RecipientAddressEditorDialogProps = { + entry: Record; + index: number; + locked: boolean; + recipientsSection: Record; + entryDefaults: Record; + updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void; + updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void; + updateEntryMerge: (index: number, mergeKey: NonNullable, checked: boolean) => void; + onClose: () => void; +}; + +type HeaderAddressValues = Partial>; + +type AddressHeaderControlProps = { + columns: EntryAddressColumn[]; + values: HeaderAddressValues; + emptyText: string; + disabled: boolean; + onEdit: () => void; + onCopy: () => void; +}; + +function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, onCopy }: AddressHeaderControlProps) { + const { translateText } = usePlatformLanguage(); + const textRef = useRef(null); + const [availableWidth, setAvailableWidth] = useState(0); + const segments = useMemo(() => addressDisplaySegments(columns, values), [columns, values]); + const visibleSegments = useMemo(() => fitAddressSegments(segments, availableWidth), [availableWidth, segments]); + const hiddenCount = Math.max(0, segments.length - visibleSegments.length); + const hasAddresses = segments.length > 0; + const copiedText = formatAddressCollectionForClipboard(columns, values); + + useEffect(() => { + const element = textRef.current; + if (!element) return; + const updateWidth = () => setAvailableWidth(element.clientWidth); + updateWidth(); + const observer = new ResizeObserver(updateWidth); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return ( +
+
+
+ {hasAddresses ? + <> + {visibleSegments.join(", ")} + {hiddenCount > 0 && +{hiddenCount}} + : + {translateText(emptyText)} + } +
+ + +
+
); +} + +type HeaderAddressEditorDialogProps = { + title: string; + columns: EntryAddressColumn[]; + values: HeaderAddressValues; + locked: boolean; + onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void; + onClose: () => void; +}; + +function HeaderAddressEditorDialog({ title, columns, values, locked, onAddressesChange, onClose }: HeaderAddressEditorDialogProps) { + const columnKeys = new Set(columns.map((column) => column.key)); + + function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean { + const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key)); + if (groups.length === 0) return false; + const nextValues: HeaderAddressValues = { ...values }; + for (const group of groups) { + nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]); + } + for (const group of groups) { + onAddressesChange(group.key, nextValues[group.key] ?? []); + } + return true; + } + + return ( + i18n:govoplan-campaign.close.bbfa773e}> + +
+ {columns.map((column) => + onAddressesChange(column.key, addresses)} + onPasteAddresses={applyPastedAddresses} /> + + )} +
+
); +} + +function RecipientAddressEditorDialog({ + entry, + index, + locked, + recipientsSection, + entryDefaults, + updateEntryAddressList, + updateEntryAddressGroups, + updateEntryMerge, + onClose +}: RecipientAddressEditorDialogProps) { + const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key)); + const availableKeys = new Set(availableColumns.map((column) => column.key)); + + function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean { + const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key)); + if (groups.length === 0) return false; + updateEntryAddressGroups(index, groups); + return true; + } + + return ( + i18n:govoplan-campaign.close.bbfa773e}> + +
+ {availableColumns.length === 0 && +

No individual recipient address fields are enabled.

+ } + {availableColumns.map((column) => + updateEntryAddressList(index, column.key, addresses)} + onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined} + onPasteAddresses={applyPastedAddresses} /> + + )} +
+
); +} + +type RecipientAddressCategoryEditorProps = { + column: EntryAddressColumn; + addresses: MailboxAddress[]; + merge: boolean; + locked: boolean; + onAddressesChange: (addresses: MailboxAddress[]) => void; + onMergeChange?: (merge: boolean) => void; + onPasteAddresses?: (targetKey: AddressFieldKey, text: string) => boolean; +}; + +function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAddressesChange, onMergeChange, onPasteAddresses }: RecipientAddressCategoryEditorProps) { + const { translateText } = usePlatformLanguage(); + const [draftAddresses, setDraftAddresses] = useState(() => normalizeEditorAddressRows(column, addresses)); + const canAdd = !locked && column.allowMultiple; + const translatedAddLabel = translateText(column.addLabel); + const translatedMoveUpLabel = "Move address up"; + const translatedMoveDownLabel = "Move address down"; + const translatedRemoveLabel = "Remove address"; + + useEffect(() => { + setDraftAddresses(normalizeEditorAddressRows(column, addresses)); + }, [addresses, column]); + + function commitAddresses(nextAddresses: MailboxAddress[]) { + setDraftAddresses(nextAddresses); + onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim())); + } + + function patchAddress(addressIndex: number, patch: Partial) { + commitAddresses(draftAddresses.map((address, currentIndex) => currentIndex === addressIndex ? { ...address, ...patch } : address)); + } + + function addAddress(afterIndex = draftAddresses.length - 1) { + if (!column.allowMultiple) return; + const insertIndex = Math.max(0, Math.min(afterIndex + 1, draftAddresses.length)); + const nextAddresses = [ + ...draftAddresses.slice(0, insertIndex), + { name: "", email: "" }, + ...draftAddresses.slice(insertIndex)]; + + setDraftAddresses(nextAddresses); + } + + function removeAddress(addressIndex: number) { + commitAddresses(draftAddresses.filter((_, currentIndex) => currentIndex !== addressIndex)); + } + + function moveAddress(addressIndex: number, targetIndex: number) { + if (addressIndex === targetIndex) return; + commitAddresses(moveArrayItem(draftAddresses, addressIndex, targetIndex)); + } + + function handlePaste(event: ClipboardEvent) { + if (locked || !onPasteAddresses) return; + const text = event.clipboardData.getData("text/plain"); + if (onPasteAddresses(column.key, text)) event.preventDefault(); + } + + return ( +
+
+

{column.label}

+
+ {onMergeChange ? + : + null} +
+
+ +
+ {column.allowMultiple && draftAddresses.length === 0 && +
+

{column.emptyText}

+
+ +
+
+ } + {draftAddresses.map((address, addressIndex) => +
+ patchAddress(addressIndex, { name: event.target.value })} /> + patchAddress(addressIndex, { email: event.target.value })} /> + {column.allowMultiple && +
+ + + + +
+ } +
+ )} +
+
); + +} + type AddressSourceImportDialogProps = { settings: ApiSettings; campaignId: string; @@ -1709,68 +2064,122 @@ function suggestImportFieldName(value: string): string { return cleaned || "field"; } -function mailboxAddressesFromLookupCandidates(candidates: CampaignAddressLookupCandidate[]): MailboxAddress[] { - return dedupeAddresses( - candidates. - map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null). - filter((address): address is MailboxAddress => Boolean(address?.email)) - ); -} - type RecipientProfileColumnContext = { + settings: ApiSettings; + campaignId: string; + draft: Record; locked: boolean; + filesModuleInstalled: boolean; entries: Record[]; - entryDefaults: Record; - entryAddressColumns: EntryAddressColumn[]; - addressSuggestions: MailboxAddress[]; - onAddressSuggestionQueryChange: (query: string) => void; + fieldDefinitions: ReturnType; + individualAttachmentBasePaths: ReturnType; + zipConfig: AttachmentZipCollection; translateText: (value: string) => string; - updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void; - updateEntryMerge: (index: number, mergeKey: NonNullable, checked: boolean) => void; + openAddressEditor: (index: number) => void; updateEntry: (index: number, updater: (entry: Record) => Record) => 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; }; -function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, onAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] { +function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] { return [ - { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => {index + 1}, value: (_entry, index) => index + 1 }, - ...entryAddressColumns.map((column): DataGridColumn> => ({ - id: column.key, - header: column.label, - width: column.key === "to" ? "minmax(260px, 1.2fr)" : 250, + { + id: "number", + header: "#", + width: 72, + sortable: true, + filterType: "integer", + sticky: "start", + render: (_entry, index) => + + {index + 1} + , + + value: (_entry, index) => index + 1 + }, + { + id: "recipients", + header: "Recipient(s)", + width: "minmax(320px, 1.4fr)", resizable: true, filterable: true, - render: (entry, index) => -
- updateEntryAddressList(index, column.key, addresses)} /> - - {column.mergeKey && -
- updateEntryMerge(index, column.mergeKey!, checked)} /> - - {translateText("i18n:govoplan-campaign.merge_with_global.ba230098")} {translateText(column.label)} -
+ render: (entry, index) => { + const summary = recipientAddressSummary(entry, translateText); + const content = ( + <> + + {summary.primary} + {summary.badges.map((badge) => + {badge} + )} + + {!locked &&
, - - value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ") - })), + return ( + ); + }, + 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) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" }, + ...(individualAttachmentBasePaths.length > 0 ? [{ + id: "attachments", + header: "i18n:govoplan-campaign.attachments.6771ade6", + width: 180, + filterable: true, + render: (entry, index) => { + const attachments = normalizeAttachmentRules(entry.attachments); + return ( + 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> => ({ + id: `field-${field.name}`, + header: field.label || field.name, + width: 190, + 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 ( + updateEntryField(index, field.name, value)} />); + + + }, + value: (entry) => String(asRecord(entry.fields)[field.name] ?? "") + })), { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", @@ -1793,6 +2202,166 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC } +function recipientAddressSummary(entry: Record, translateText: (value: string) => string): {primary: string;badges: string[];empty: boolean;} { + const to = getEntryAddresses(entry, "to"); + const primaryAddress = to[0] ?? null; + const badges = [ + addressCountBadge(Math.max(0, to.length - 1), "To"), + addressCountBadge(getEntryAddresses(entry, "cc").length, "CC"), + addressCountBadge(getEntryAddresses(entry, "bcc").length, "BCC"), + addressCountBadge(getEntryAddresses(entry, "reply_to").length, "Reply-To"), + addressCountBadge(getEntryAddresses(entry, "from").length, "From")]. + filter((badge): badge is string => Boolean(badge)); + + if (!primaryAddress) { + return { + primary: translateText("i18n:govoplan-campaign.no_recipient_address.1301a488"), + badges, + empty: true + }; + } + + return { + primary: formatMailboxAddress(primaryAddress), + badges, + empty: false + }; +} + +function recipientAddressFilterValue(entry: Record): string { + return recipientAddressOverlayColumns. + flatMap((column) => getEntryAddresses(entry, column.key)). + map(formatMailboxAddress). + join(", "); +} + +function addressCountBadge(count: number, label: string): string | null { + if (count <= 0) return null; + return `+${count} ${label}`; +} + +function getAddressColumn(key: AddressFieldKey): EntryAddressColumn { + const column = recipientAddressOverlayColumns.find((item) => item.key === key); + if (!column) throw new Error(`Unknown address column ${key}`); + return column; +} + +function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] { + if (column.allowMultiple) return addresses; + return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }]; +} + +function formatMailboxAddress(address: MailboxAddress): string { + const name = String(address.name ?? "").trim(); + const email = String(address.email ?? "").trim(); + if (name && email) return `${name} <${email}>`; + return email || name; +} + +function entryWithAddressList(entry: Record, key: AddressFieldKey, addresses: MailboxAddress[]): Record { + if (key === "from") return { ...entry, from: addresses.slice(0, 1) }; + const nextEntry = { ...entry, [key]: addresses }; + if (key === "to") { + const address = addresses[0] ?? { name: "", email: "" }; + return { + ...nextEntry, + name: address.name ?? "", + email: address.email + }; + } + return nextEntry; +} + +function headerAddressValues(columns: EntryAddressColumn[], recipientsSection: Record): HeaderAddressValues { + return Object.fromEntries(columns.map((column) => [ + column.key, + column.key === "from" ? addressesFromValue(recipientsSection.from).slice(0, 1) : addressesFromValue(recipientsSection[column.key])] + )) as HeaderAddressValues; +} + +function addressDisplaySegments(columns: EntryAddressColumn[], values: HeaderAddressValues): string[] { + return columns.flatMap((column) => + (values[column.key] ?? []).map((address) => `${addressDisplayPrefix(column.key)}${formatMailboxAddress(address)}`) + ).filter(Boolean); +} + +function fitAddressSegments(segments: string[], availableWidth: number): string[] { + if (segments.length <= 1 || availableWidth <= 0) return segments; + if (measureAddressTextWidth(segments.join(", ")) <= availableWidth) return segments; + const maxWidth = Math.max(120, availableWidth - 42); + let usedWidth = 0; + const visible: string[] = []; + for (const segment of segments) { + const estimatedWidth = measureAddressTextWidth(`${visible.length > 0 ? ", " : ""}${segment}`); + if (visible.length > 0 && usedWidth + estimatedWidth > maxWidth) break; + visible.push(segment); + usedWidth += estimatedWidth; + } + return visible.length > 0 ? visible : segments.slice(0, 1); +} + +function measureAddressTextWidth(value: string): number { + if (typeof document === "undefined") return value.length * 6.6; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) return value.length * 6.6; + context.font = "13px system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"; + return context.measureText(value).width; +} + +function formatAddressCollectionForClipboard(columns: EntryAddressColumn[], values: HeaderAddressValues): string { + return addressDisplaySegments(columns, values).join(", "); +} + +function addressDisplayPrefix(key: AddressFieldKey): string { + if (key === "from") return "From: "; + if (key === "reply_to") return "Reply-To: "; + if (key === "to") return "To: "; + if (key === "cc") return "CC: "; + return "BCC: "; +} + +function parsePastedAddressGroups(targetKey: AddressFieldKey, text: string): Array<{key: AddressFieldKey;addresses: MailboxAddress[];}> { + const tokens = splitPastedAddressText(text); + const hasPrefixedToken = tokens.some((token) => Boolean(prefixedAddressToken(token))); + const grouped = new Map(); + for (const token of tokens) { + const prefixed = prefixedAddressToken(token); + const key = prefixed?.key ?? (hasPrefixedToken ? "to" : targetKey); + const addressText = prefixed?.text ?? token; + const address = parseMailboxAddressText(addressText); + if (!address?.email) continue; + grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address])); + } + return [...grouped.entries()].map(([key, addresses]) => ({ key, addresses })); +} + +function splitPastedAddressText(text: string): string[] { + return text. + replace(/\r/g, "\n"). + split(/\n|;/). + flatMap((part) => part.split(",")). + map((part) => part.trim()). + filter(Boolean); +} + +function prefixedAddressToken(token: string): {key: AddressFieldKey;text: string;} | null { + const match = token.match(/^(from|sender|reply[-_\s]?to|to|cc|bcc)\s*:\s*(.+)$/i); + if (!match) return null; + const key = addressKeyFromPrefix(match[1]); + return key ? { key, text: match[2].trim() } : null; +} + +function addressKeyFromPrefix(prefix: string): AddressFieldKey | null { + const normalized = prefix.toLowerCase().replace(/[-_\s]/g, ""); + if (normalized === "from" || normalized === "sender") return "from"; + if (normalized === "replyto") return "reply_to"; + if (normalized === "to") return "to"; + if (normalized === "cc") return "cc"; + if (normalized === "bcc") return "bcc"; + return null; +} + function getEntryMerge(entry: Record, defaults: Record, mergeKey: NonNullable): boolean { const legacyKey = mergeKey.replace("merge_", "combine_"); if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean; @@ -1802,6 +2371,10 @@ function getEntryMerge(entry: Record, defaults: Record, key: EntryAddressColumn["key"]): boolean { + return getBool(recipientsSection, `allow_individual_${key}`, key === "to"); +} + function getEntryAddresses(entry: Record, key: EntryAddressColumn["key"]): MailboxAddress[] { if (key === "to") { const explicit = addressesFromValue(entry.to); diff --git a/webui/src/features/campaigns/RecipientDetailsPage.tsx b/webui/src/features/campaigns/RecipientDetailsPage.tsx deleted file mode 100644 index 68fea43..0000000 --- a/webui/src/features/campaigns/RecipientDetailsPage.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { useMemo, useState } from "react"; -import { Link } from "react-router-dom"; -import { usePlatformModuleInstalled } from "@govoplan/core-webui"; -import type { ApiSettings } from "../../types"; -import { Button } from "@govoplan/core-webui"; -import { Card } from "@govoplan/core-webui"; -import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold"; -import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; -import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; -import FieldValueInput from "./components/FieldValueInput"; -import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay"; -import { getDraftFields } from "./utils/fieldDefinitions"; -import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments"; -import { materializeRecipientImportWithAttachmentDefaults, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport"; -import { buildTemplatePreviewContext } from "./utils/templatePlaceholders"; -import { RecipientImportDialog } from "./RecipientDataPage"; -import { addressesFromValue } from "@govoplan/core-webui"; -import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui"; -import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; - - -export default function RecipientDetailsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { - const filesModuleInstalled = usePlatformModuleInstalled("files"); - const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); - const [importOpen, setImportOpen] = useState(false); - const [recipientDataPage, setRecipientDataPage] = useState(1); - const [recipientDataPageSize, setRecipientDataPageSize] = useState(10); - - const version = data.currentVersion; - const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); - const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ - settings, - campaignId, - version, - locked, - reload, - setError, - currentStep: "recipient-data", - unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e", - unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870" - }); - const entries = asRecord(displayDraft.entries); - const inlineEntries = asArray(entries.inline).map(asRecord); - const source = asRecord(entries.source); - const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]); - const attachmentSection = asRecord(displayDraft.attachments); - const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]); - const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]); - const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]); - const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]); - const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]); - - - function replaceInlineEntries(nextEntries: Record[]) { - patch(["entries", "inline"], nextEntries); - } - - function updateEntry(index: number, updater: (entry: Record) => Record) { - const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry); - replaceInlineEntries(nextEntries); - } - - function updateEntryField(index: number, field: string, value: unknown) { - updateEntry(index, (entry) => ({ - ...entry, - fields: { - ...asRecord(entry.fields), - [field]: value - } - })); - } - - function updateEntryAttachments(index: number, attachments: AttachmentRule[]) { - updateEntry(index, (entry) => ({ ...entry, attachments })); - } - - function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) { - if (locked || !draft) return; - setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode })); - markDirty(); - setImportOpen(false); - } - - - return ( - <> - saveDraft("manual")} - dirty={dirty} - locked={locked} - draft={draft} - error={error} - localError={localError} - currentVersionId={data.campaign?.current_version_id}> - - setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2}> - {inlineEntries.length === 0 && !source.type &&

i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224

} - {inlineEntries.length === 0 && Boolean(source.type) && - i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252 - } - {inlineEntries.length > 0 && -
- String(entry.id || index)} - emptyText="i18n:govoplan-campaign.no_recipient_data_found.a12be7d0" - className="recipient-table-wrap recipient-data-table-wrap recipient-data-table" - pagination={{ - page: recipientDataPage, - pageSize: recipientDataPageSize, - pageSizeOptions: [10, 25, 50, 100, 250], - onPageChange: setRecipientDataPage, - onPageSizeChange: (pageSize) => { - setRecipientDataPageSize(pageSize); - setRecipientDataPage(1); - } - }} /> - -
- } -
-
- - {importOpen && - setImportOpen(false)} - onImport={applyRecipientImport} /> - - } - ); - -} - -type RecipientDataColumnContext = { - settings: ApiSettings; - campaignId: string; - draft: Record; - locked: boolean; - filesModuleInstalled: boolean; - fieldDefinitions: ReturnType; - individualAttachmentBasePaths: ReturnType; - zipConfig: AttachmentZipCollection; - updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void; - updateEntryField: (index: number, field: string, value: unknown) => void; -}; - -function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn>[] { - return [ - { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => {index + 1}, value: (_entry, index) => index + 1 }, - { - id: "recipient", - header: "i18n:govoplan-campaign.recipient.90343260", - width: 260, - resizable: true, - sortable: true, - filterable: true, - sticky: "start", - render: (entry) => - - {firstRecipientEmail(entry) || "i18n:govoplan-campaign.no_to_address.683350f9"} - {extraRecipientCount(entry) > 0 && +{extraRecipientCount(entry)}} - , - - value: firstRecipientEmail - }, - { - id: "attachments", - header: "i18n:govoplan-campaign.attachments.6771ade6", - width: 180, - filterable: true, - render: (entry, index) => { - const attachments = normalizeAttachmentRules(entry.attachments); - return ( - updateEntryAttachments(index, rules)} />); - - - }, - value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ") - }, - ...fieldDefinitions.map((field): DataGridColumn> => ({ - id: `field-${field.name}`, - header: field.label || field.name, - width: 190, - 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 ( - updateEntryField(index, field.name, value)} />); - - - }, - value: (entry) => String(asRecord(entry.fields)[field.name] ?? "") - }))]; - -} - -function firstRecipientEmail(entry: Record): string { - return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? ""; -} - -function extraRecipientCount(entry: Record): number { - const count = addressesFromValue(entry.to).length; - return Math.max(0, count - 1); -} diff --git a/webui/src/layout/SectionSidebar.tsx b/webui/src/layout/SectionSidebar.tsx index 6eb425e..5200d03 100644 --- a/webui/src/layout/SectionSidebar.tsx +++ b/webui/src/layout/SectionSidebar.tsx @@ -11,7 +11,6 @@ const campaignSubnav: ModuleSubnavGroup[] = [ { id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" }, { id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" }, { id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" }, - { id: "recipient-data", label: "i18n:govoplan-campaign.recipient_data.c2baaf10" }, { id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }] }, @@ -56,4 +55,4 @@ export default function SectionSidebar({ }: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) { return ; -} \ No newline at end of file +}