import { MetricGrid } from "@govoplan/core-webui"; import { useEffect, useMemo, useState } from "react"; import { Pencil } from "lucide-react"; import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui"; import type { ApiSettings, AuthInfo } from "../../types"; import { getCampaignArchiveEncryptionPolicy, type CampaignArchiveEncryptionPolicy } from "../../api/campaigns"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { PageActionBar, PageLayout } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import { ToggleSwitch } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { updateNested } from "./utils/draftEditor"; import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog"; import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments"; import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui"; import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions"; import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; type PathChooserState = {index: number;}; type IndividualDisableState = {index: number;usageCount: number;}; const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = { available: false, allowed_password_encryption_methods: ["aes"], allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"], policy_hash: "", source_path: [], reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.", diagnostics: [], legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption" }; export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) { const navigate = useGuardedNavigate(); const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesFileExplorer = usePlatformUiCapability("files.fileExplorer"); const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null; const managedFilesAvailable = Boolean(ManagedFileChooser); const listManagedFileSpaces = filesModuleInstalled ? filesFileExplorer?.listFileSpaces : undefined; const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [pathChooser, setPathChooser] = useState(null); const [fileSpaces, setFileSpaces] = useState([]); const [individualDisable, setIndividualDisable] = useState(null); const [zipNameEditorIndex, setZipNameEditorIndex] = useState(null); const [archivePolicy, setArchivePolicy] = useState(UNAVAILABLE_ARCHIVE_POLICY); const version = data.currentVersion; const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({ settings, campaignId, version, locked, reload, setError, currentStep: "files", unsavedTitle: "i18n:govoplan-campaign.unsaved_attachment_settings.a6045f67", unsavedMessage: "i18n:govoplan-campaign.attachment_settings_have_unsaved_changes_save_th.b9b415bb" }); const attachments = asRecord(displayDraft.attachments); const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); const reusePolicy = asRecord(attachments.reuse_policy); const reuseAction = ["allow", "warn", "review", "block"].includes(String(reusePolicy.action)) ? String(reusePolicy.action) : "allow"; const reuseAllowance = ["same_recipient", "same_message"].includes(String(reusePolicy.allow_within)) ? String(reusePolicy.allow_within) : "none"; const residualFiles = asRecord(attachments.residual_files); const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none"; const residualRecipient = asRecord(residualFiles.recipient); const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]); const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]); const zipArchiveNameValidation = useMemo( () => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION, [zipConfig.archives, zipConfig.enabled] ); const canUseLegacyZipCrypto = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto"); const legacyZipCryptoAllowed = archivePolicy.available && archivePolicy.allowed_password_encryption_methods.includes("zip_standard"); const legacyConfigurationInvalid = zipConfig.archives.some((archive) => archive.method === "zip_standard" && ( !legacyZipCryptoAllowed || !canUseLegacyZipCrypto || !archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10 ) ); const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid; const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]); const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]); const attachmentPreviewEntry = useMemo( () => asArray(asRecord(displayDraft.entries).inline).map(asRecord).find((entry) => entry.active !== false) ?? {}, [displayDraft.entries] ); const attachmentPreviewContext = useMemo( () => buildTemplatePreviewContext(displayDraft, attachmentPreviewEntry), [attachmentPreviewEntry, displayDraft] ); useEffect(() => { if (!listManagedFileSpaces) { setFileSpaces([]); return; } let cancelled = false; void listManagedFileSpaces(settings). then((response) => {if (!cancelled) setFileSpaces(response.spaces);}). catch(() => {if (!cancelled) setFileSpaces([]);}); return () => {cancelled = true;}; }, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { let cancelled = false; setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY); void getCampaignArchiveEncryptionPolicy(settings, campaignId) .then((policy) => { if (!cancelled) setArchivePolicy(policy); }) .catch((cause) => { if (!cancelled) { setArchivePolicy({ ...UNAVAILABLE_ARCHIVE_POLICY, reason: cause instanceof Error ? cause.message : String(cause) }); } }); return () => { cancelled = true; }; }, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); function patchBasePaths(paths: AttachmentBasePath[]) { if (locked) return; const normalized = ensureAttachmentBasePaths(paths); setDraft((current) => { const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized); return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || "."); }); markDirty(); } function patchResidualFiles(next: Record) { if (locked) return; patch(["attachments", "residual_files"], { mode: "none", recipient: null, subject: "Unassigned files in campaign {{local:campaign_name}}", text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}", ...residualFiles, ...next }); } function patchReusePolicy(next: Record) { if (locked) return; patch(["attachments", "reuse_policy"], { action: "allow", allow_within: "none", ...reusePolicy, ...next }); } function patchBasePath(index: number, patch: Partial) { patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath)); } function addBasePath(afterIndex = basePaths.length - 1) { patchBasePaths(insertAfter(basePaths, afterIndex, createAttachmentBasePath("New attachment source", "."))); } function removeBasePath(index: number) { patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index)); } function moveBasePath(index: number, targetIndex: number) { if (locked || index === targetIndex) return; patchBasePaths(moveArrayItem(basePaths, index, targetIndex)); } function setIndividualEligibility(index: number, checked: boolean) { if (locked) return; if (checked) { patchBasePath(index, { allow_individual: true }); return; } const basePath = basePaths[index]; if (!basePath) return; const usageCount = countIndividualAttachmentRulesForBasePath(displayDraft.entries, basePath); if (usageCount > 0) { setIndividualDisable({ index, usageCount }); return; } patchBasePath(index, { allow_individual: false }); } function confirmIndividualDisable() { if (!individualDisable) return; const basePath = basePaths[individualDisable.index]; if (!basePath) { setIndividualDisable(null); return; } const nextPaths = basePaths.map((item, index) => index === individualDisable.index ? { ...item, allow_individual: false } : item); setDraft((current) => { const source = current ?? {}; const withPaths = updateNested(source, ["attachments", "base_paths"], nextPaths); const withPrimaryPath = updateNested(withPaths, ["attachments", "base_path"], nextPaths[0]?.path || "."); return updateNested(withPrimaryPath, ["entries"], removeIndividualAttachmentRulesForBasePath(asRecord(source).entries, basePath)); }); markDirty(); setIndividualDisable(null); } function patchZipCollection(next: AttachmentZipCollection) { if (locked) return; patch(["attachments", "zip"], next); } function setZipEnabled(enabled: boolean) { const archives = enabled && zipConfig.archives.length === 0 ? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] : zipConfig.archives; patchZipCollection({ enabled, archives }); } function patchZipArchive(index: number, archivePatch: Partial) { const archives = zipConfig.archives.map((archive, currentIndex) => { if (currentIndex !== index) return archive; const next = { ...archive, ...archivePatch }; if (archivePatch.password_field !== undefined || archivePatch.password_scope !== undefined) { next.password_mode = "field"; delete next.password; delete next.password_template; } return next; }); patchZipCollection({ ...zipConfig, archives }); } function setStandardZipArchive(index: number) { patchZipCollection({ ...zipConfig, archives: zipConfig.archives.map((archive, currentIndex) => ({ ...archive, standard: currentIndex === index })) }); } function addZipArchive(afterIndex = zipConfig.archives.length - 1) { const archive = createAttachmentZipArchive(`attachments-${zipConfig.archives.length + 1}.zip`, zipConfig.archives.length === 0); patchZipCollection({ ...zipConfig, archives: insertAfter(zipConfig.archives, afterIndex, archive) }); } function removeZipArchive(index: number) { const removed = zipConfig.archives[index]; if (!removed) return; const removedWasStandard = removed.standard; const archives = zipConfig.archives.filter((_, currentIndex) => currentIndex !== index); if (removedWasStandard && archives.length > 0) archives[0] = { ...archives[0], standard: true }; const resetRule = (value: unknown) => { const rule = asRecord(value); const ruleZip = asRecord(rule.zip); return String(ruleZip.archive_id ?? "") === removed.id ? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } } : rule; }; setDraft((current) => { const source = asRecord(current ?? {}); const currentAttachments = asRecord(source.attachments); const currentEntries = asRecord(source.entries); return { ...source, attachments: { ...currentAttachments, zip: { ...zipConfig, archives }, global: asArray(currentAttachments.global).map(resetRule) }, entries: { ...currentEntries, inline: asArray(currentEntries.inline).map((value) => { const entry = asRecord(value); return { ...entry, attachments: asArray(entry.attachments).map(resetRule) }; }) } }; }); markDirty(); } function moveZipArchive(index: number, targetIndex: number) { if (locked || index === targetIndex) return; patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) }); } function revealAttachmentSection(id: string) { const section = document.getElementById(id); if (!section) return; section.querySelector('.card-collapse-toggle[aria-expanded="false"]')?.click(); window.requestAnimationFrame(() => { section.scrollIntoView({ behavior: "smooth", block: "start" }); section.focus({ preventScroll: true }); }); } function openRecipients() { const query = version?.id ? `?version=${encodeURIComponent(version.id)}` : ""; navigate(`/campaigns/${campaignId}/recipients${query}`); } return ( } headerLoading={loading} error={error} actions={ navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7 : undefined} discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }} saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current editor blocker before saving." : undefined }} />} notices={(localError || locked) ? <> {localError && {localError}} {locked && } : undefined} > <> revealAttachmentSection("campaign-attachment-sources") }} /> revealAttachmentSection("campaign-global-attachments") }} />
basePath.id} emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606" emptyAction={ addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />} className="attachment-sources-table-wrap attachment-sources-table" />
Every repeated-file finding is retained in the build protocol. Review requires a reason bound to the exact build; Block prevents affected messages from being queued.
{residualMode !== "none" && <> patchResidualFiles({ recipient: { ...residualRecipient, email: event.target.value } })} /> patchResidualFiles({ recipient: { ...residualRecipient, name: event.target.value || null } })} />
patchResidualFiles({ subject: event.target.value })} />