From 0a6064ec6292bced6d24dde3d5c08106802c65b3 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 20:08:41 +0200 Subject: [PATCH] feat(campaign-ui): review and link managed attachments --- .gitignore | 2 + webui/package.json | 3 +- .../src/features/campaigns/ReviewSendPage.tsx | 783 +++++++++++++++--- .../features/campaigns/TemplateDataPage.tsx | 34 +- .../components/MessagePreviewOverlay.tsx | 9 +- .../campaigns/utils/attachmentPreview.ts | 53 ++ webui/src/i18n/generatedTranslations.ts | 54 +- webui/tests/review-preview-ui.test.ts | 113 +++ webui/tsconfig.review-preview-tests.json | 20 + 9 files changed, 921 insertions(+), 150 deletions(-) create mode 100644 webui/src/features/campaigns/utils/attachmentPreview.ts create mode 100644 webui/tests/review-preview-ui.test.ts create mode 100644 webui/tsconfig.review-preview-tests.json diff --git a/.gitignore b/.gitignore index 00a3524..7f51d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -332,6 +332,7 @@ cython_debug/ webui/.policy-test-build/ webui/.template-preview-test-build/ webui/.import-test-build/ +webui/.review-preview-test-build/ # GovOPlaN shared ignore rules from govoplan-core # Local WebUI test/build scratch directories @@ -340,6 +341,7 @@ webui/.import-test-build/ .policy-test-build/ .template-preview-test-build/ .import-test-build/ +.review-preview-test-build/ webui/.component-test-build/ webui/.module-test-build/ *.db diff --git a/webui/package.json b/webui/package.json index fec0a74..14eb937 100644 --- a/webui/package.json +++ b/webui/package.json @@ -26,7 +26,8 @@ "scripts": { "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js", - "test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js" + "test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js", + "test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index 76df7bd..ad63645 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -1,26 +1,34 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { BarChart3, Check, ChevronDown, FlaskConical, + Link2, LockKeyhole, PackageCheck, Send, ShieldCheck, + X, type LucideIcon } from "lucide-react"; import type { ApiSettings } from "../../types"; import { appendSent, buildVersion, + getCampaignJobs, getCampaignJobsDelta, getCampaignJobDetail, getCampaignSummary, + linkCampaignAttachmentMatches, mockSendCampaign, + previewCampaignAttachments, + sendCampaignJob, sendCampaignNow, updateCampaignReviewState, validateVersion, + type CampaignAttachmentPreviewFile, + type CampaignAttachmentPreviewResponse, type CampaignJobsQuery, type CampaignJobsResponse, type CampaignSummary, @@ -28,7 +36,7 @@ import { "../../api/campaigns"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; -import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid"; +import DataGrid, { type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "../../components/table/DataGrid"; import { DismissibleAlert } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; @@ -55,6 +63,7 @@ import { stringifyPreview } from "./utils/campaignView"; import { getBool, getText } from "./utils/draftEditor"; +import { attachmentPreviewLinkableFiles, attachmentPreviewMatchedFiles } from "./utils/attachmentPreview"; import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas"; import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders"; @@ -76,6 +85,7 @@ type FlowStageDefinition = { description: string; icon: LucideIcon; state: FlowState; + connectorState?: FlowState; stateLabel: string; lockReason?: string; }; @@ -95,8 +105,8 @@ const stateColors: Record = { active: "var(--blue)", locked: "var(--line-dark)", running: "var(--blue)", - partial: "var(--review-flow-purple)", - stale: "var(--review-flow-purple)", + partial: "var(--review-flow-partial)", + stale: "var(--review-flow-partial)", pending: "var(--muted)" }; @@ -150,17 +160,24 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS const [builtReviewRows, setBuiltReviewRows] = useState[]>([]); const [reviewJobs, setReviewJobs] = useState(() => emptyCampaignJobsResponse()); const reviewJobsRef = useRef(emptyCampaignJobsResponse()); - const jobPageCursorsRef = useRef>>({}); const [reviewPage, setReviewPage] = useState(1); + const [reviewPageSize, setReviewPageSize] = useState(50); + const [reviewQuery, setReviewQuery] = useState({ sort: null, filters: {} }); const [showAllReviewJobs, setShowAllReviewJobs] = useState(false); const [jobsLoadedKey, setJobsLoadedKey] = useState(""); const [reviewedMessageKeys, setReviewedMessageKeys] = useState>(() => new Set()); const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState>(() => new Set()); const [selectedBuiltIndex, setSelectedBuiltIndex] = useState(null); + const [singleSendConfirmIndex, setSingleSendConfirmIndex] = useState(null); const [mockResult, setMockResult] = useState | null>(null); const [mockClearFirst, setMockClearFirst] = useState(true); const [mockAppendSent, setMockAppendSent] = useState(true); const [selectedMockMessage, setSelectedMockMessage] = useState(null); + const [attachmentPreview, setAttachmentPreview] = useState(null); + const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false); + const [attachmentPreviewError, setAttachmentPreviewError] = useState(""); + const [attachmentLinking, setAttachmentLinking] = useState(false); + const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false); const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false); const [dryRun, setDryRun] = useState(false); const [sendConfirmOpen, setSendConfirmOpen] = useState(false); @@ -177,11 +194,15 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS setReviewJobs(emptyCampaignJobsResponse()); reviewJobsRef.current = emptyCampaignJobsResponse(); setReviewPage(1); + setReviewQuery({ sort: null, filters: {} }); setJobsLoadedKey(""); setNewlyReviewedRequiredKeys(new Set()); setSelectedBuiltIndex(null); setMockResult(null); setSelectedMockMessage(null); + setAttachmentPreview(null); + setAttachmentPreviewError(""); + setAttachmentLockConfirmOpen(false); setSendResult(null); setImapAppendResult(null); setImapDiagnostics(emptyCampaignJobsResponse()); @@ -219,10 +240,10 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS useEffect(() => { if (!version?.id || !hasBuild) return; - const expectedKey = reviewJobsDeltaKey(version.id, reviewPage, showAllReviewJobs); + const expectedKey = reviewJobsLoadKey(version.id, showAllReviewJobs); if (jobsLoadedKey === expectedKey) return; - void loadBuiltMessages(true, reviewPage); - }, [version?.id, hasBuild, reviewPage, showAllReviewJobs, jobsLoadedKey, campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + void loadBuiltMessages(true); + }, [version?.id, hasBuild, showAllReviewJobs, jobsLoadedKey, campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); const statusCounts = asRecord(summary?.status_counts); const sendStatusCounts = asRecord(statusCounts.send); @@ -313,7 +334,42 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS return () => window.clearTimeout(handle); }, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]); - const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null; + const reloadAttachmentPreview = useCallback(async (silent = true) => { + if (!version?.id) return; + setAttachmentPreviewLoading(true); + setAttachmentPreviewError(""); + if (!silent) setMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"); + try { + const response = await previewCampaignAttachments(settings, campaignId, version.id, { + include_unmatched: true, + include_unlinked_candidates: true + }); + setAttachmentPreview(response); + if (!silent) setMessage("i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d"); + } catch (err) { + setAttachmentPreview(null); + setAttachmentPreviewError(err instanceof Error ? err.message : String(err)); + if (!silent) setMessage(""); + } finally { + setAttachmentPreviewLoading(false); + } + }, [campaignId, settings, version?.id]); + + useEffect(() => { + if (!version?.id) return; + void reloadAttachmentPreview(true); + }, [reloadAttachmentPreview, version?.id, version?.updated_at]); + + const filteredBuiltReviewRows = useMemo( + () => filterAndSortBuiltMessageRows(builtReviewRows, reviewQuery, reviewedMessageKeys), + [builtReviewRows, reviewQuery, reviewedMessageKeys] + ); + const reviewColumns = builtMessageColumns(openBuiltMessage, reviewedMessageKeys); + const reviewPageCount = Math.max(1, Math.ceil(filteredBuiltReviewRows.length / reviewPageSize)); + const effectiveReviewPage = Math.min(reviewPageCount, Math.max(1, reviewPage)); + const pagedBuiltReviewRows = filteredBuiltReviewRows.slice((effectiveReviewPage - 1) * reviewPageSize, effectiveReviewPage * reviewPageSize); + const selectedBuiltMessage = selectedBuiltIndex === null ? null : filteredBuiltReviewRows[selectedBuiltIndex] ?? null; + const singleSendConfirmRow = singleSendConfirmIndex === null ? null : filteredBuiltReviewRows[singleSendConfirmIndex] ?? null; const reviewMetadata = reviewJobs.review ?? {}; const blockingReviewCount = Number(reviewMetadata.blocking_count ?? 0); const explicitReviewCount = Number(reviewMetadata.required_count ?? 0); @@ -373,6 +429,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS "complete" : "active"; + const optionalMockSkipped = mockWorkflowAvailable && !mockWorkflowRequired && inspectionSatisfied && !mockResult && busy !== "mock" && busy !== "mailbox"; const mockState: FlowState = !mockWorkflowAvailable ? "locked" : !inspectionSatisfied ? @@ -385,12 +442,16 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS "danger" : mockComplete ? "complete" : + optionalMockSkipped ? + "complete" : downstreamDeliveryActivity ? "warning" : "active"; const mockStateDisplayLabel = !mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : !mockVerificationRequired ? "i18n:govoplan-campaign.optional.0c6c4102" : stateLabel(mockState); - const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || downstreamDeliveryActivity); + const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || mockPartial || downstreamDeliveryActivity); + const singleMessageSendWorkflowBlocked = ["archived", "cancelled"].includes(currentWorkflowState); + const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !singleMessageSendWorkflowBlocked); const sendLockReason = !inspectionSatisfied ? "i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" : mockWorkflowRequired ? @@ -453,6 +514,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS description: "i18n:govoplan-campaign.exercise_the_delivery_path_and_verify_recipient_.12ed1928", icon: FlaskConical, state: mockState, + connectorState: !mockWorkflowAvailable && inspectionSatisfied ? sendState : mockState, stateLabel: mockStateDisplayLabel, lockReason: mockWorkflowAvailable ? "i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" : "i18n:govoplan-campaign.enable_the_mail_dev_mailbox_capability_to_run_mo.4f9a506d" }, @@ -485,16 +547,16 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS validationErrors, mockStateDisplayLabel, mockWorkflowAvailable, + inspectionSatisfied, sendLockReason] ); - function reviewJobsDeltaKey(versionId: string, targetPage: number, includeAll: boolean): string { + function reviewJobsLoadKey(versionId: string, includeAll: boolean): string { return JSON.stringify({ scope: "review-jobs", campaignId, versionId, - page: targetPage, - pageSize: 50, + pageSize: "all", includeAll, apiBaseUrl: settings.apiBaseUrl, apiKey: settings.apiKey, @@ -502,17 +564,6 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS }); } - function reviewJobsCursorScopeKey(versionId: string, includeAll: boolean): string { - return JSON.stringify({ - scope: "review-jobs", - campaignId, - versionId, - pageSize: 50, - includeAll, - validationStatus: includeAll ? [] : MESSAGE_REVIEW_ISSUE_STATUSES - }); - } - function imapDiagnosticsDeltaKey(versionId: string): string { return JSON.stringify({ scope: "imap-diagnostics", @@ -527,29 +578,16 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS }); } - async function fetchJobsDelta(key: string, current: CampaignJobsResponse, options: CampaignJobsQuery, cursorScopeKey?: string): Promise { + async function fetchJobsDelta(key: string, current: CampaignJobsResponse, options: CampaignJobsQuery): Promise { let nextWatermark = getDeltaWatermark(key); let merged = current; let hasMore = false; - const page = options.page ?? 1; - const cursorMap = cursorScopeKey ? jobPageCursorsRef.current[cursorScopeKey] ?? { 1: null } : undefined; - const pageCursor = cursorMap ? page === 1 ? null : cursorMap[page] : undefined; do { const response = await getCampaignJobsDelta(settings, campaignId, { ...options, - cursor: pageCursor, since: nextWatermark }); merged = mergeCampaignJobsDelta(merged, response); - if (cursorScopeKey) { - const nextCursorMap = jobPageCursorsRef.current[cursorScopeKey] ?? { 1: null }; - if (response.cursor !== undefined) nextCursorMap[page] = response.cursor ?? null; - if (response.next_cursor !== undefined) { - if (response.next_cursor) nextCursorMap[page + 1] = response.next_cursor; - else delete nextCursorMap[page + 1]; - } - jobPageCursorsRef.current[cursorScopeKey] = nextCursorMap; - } nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore); @@ -557,15 +595,47 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS return merged; } - async function runValidation() { - if (!version || busy || readOnlyVersion || readyForDelivery) return; - setBusy("validate"); - setMessage("i18n:govoplan-campaign.validating_the_campaign_including_managed_attach.b133cfd9"); + async function linkMatchedAttachmentFiles() { + if (!version || busy || readOnlyVersion || readyForDelivery || attachmentLinking) return; + const pending = attachmentPreviewLinkableFiles(attachmentPreview); + if (pending.length === 0) { + await reloadAttachmentPreview(false); + return; + } + setAttachmentLinking(true); + setMessage("i18n:govoplan-campaign.linking_matched_attachment_files.92f38088"); setError(""); try { - const result = await validateVersion(settings, version.id, true); + const result = await linkCampaignAttachmentMatches(settings, campaignId, version.id, { dry_run: false }); + setMessage(i18nMessage("i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7", { value0: result.linked_file_count })); + await reloadAttachmentPreview(true); + await reload(); + } catch (err) { + setMessage(""); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setAttachmentLinking(false); + } + } + + async function runValidation(linkUnsharedMatches = false) { + if (!version || busy || readOnlyVersion || readyForDelivery) return; + const pending = attachmentPreviewLinkableFiles(attachmentPreview); + if (!linkUnsharedMatches && pending.length > 0) { + setAttachmentLockConfirmOpen(true); + return; + } + setAttachmentLockConfirmOpen(false); + setBusy("validate"); + setMessage(linkUnsharedMatches ? + "i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0" : + "i18n:govoplan-campaign.validating_the_campaign_including_managed_attach.b133cfd9"); + setError(""); + try { + const result = await validateVersion(settings, version.id, true, linkUnsharedMatches); setMessage(result.ok ? "i18n:govoplan-campaign.validation_passed.c3e25768" : "i18n:govoplan-campaign.validation_finished_with_issues_review_the_excep.be65d2a1"); resetDownstreamReview(); + await reloadAttachmentPreview(true); await reload(); } catch (err) { setMessage(""); @@ -582,22 +652,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS setError(""); try { const result = await buildVersion(settings, version.id, true); - const reviewKey = reviewJobsDeltaKey(version.id, 1, showAllReviewJobs); - const reviewCursorKey = reviewJobsCursorScopeKey(version.id, showAllReviewJobs); - resetDeltaWatermark(reviewKey); - jobPageCursorsRef.current[reviewCursorKey] = { 1: null }; - reviewJobsRef.current = emptyCampaignJobsResponse(); - const review = await fetchJobsDelta(reviewKey, reviewJobsRef.current, { - versionId: version.id, - page: 1, - pageSize: 50, - validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES - }, reviewCursorKey); - setReviewPage(1); - reviewJobsRef.current = review; - setReviewJobs(review); - setBuiltReviewRows(review.jobs.map(asRecord)); - setJobsLoadedKey(reviewKey); + applyLoadedReviewJobs(await loadAllReviewJobs(version.id, showAllReviewJobs), true); setMessage(i18nMessage("i18n:govoplan-campaign.build_finished_built_value_message_s_the_message.e1ec0296", { value0: String(result.built_count ?? result.ready_count ?? "—") })); setMessageReviewComplete(false); setReviewedMessageKeys(new Set()); @@ -612,29 +667,14 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS } } - async function loadBuiltMessages(silent = false, targetPage = reviewPage) { + async function loadBuiltMessages(silent = false) { if (!version || busy || !hasBuild) return; setBusy("inspect"); if (!silent) setMessage("i18n:govoplan-campaign.loading_the_built_message_review.a5339588"); setError(""); try { - const reviewKey = reviewJobsDeltaKey(version.id, targetPage, showAllReviewJobs); - const reviewCursorKey = reviewJobsCursorScopeKey(version.id, showAllReviewJobs); - const current = jobsLoadedKey === reviewKey ? reviewJobsRef.current : emptyCampaignJobsResponse(); - if (jobsLoadedKey !== reviewKey) resetDeltaWatermark(reviewKey); - const result = await fetchJobsDelta(reviewKey, current, { - versionId: version.id, - page: targetPage, - pageSize: 50, - validationStatus: showAllReviewJobs ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES - }, reviewCursorKey); - const jobs = result.jobs.map(asRecord); - reviewJobsRef.current = result; - setReviewJobs(result); - setBuiltReviewRows(jobs); - setJobsLoadedKey(reviewKey); - if (result.pages > 0 && targetPage > result.pages) setReviewPage(result.pages); - if (!silent) setMessage(i18nMessage("i18n:govoplan-campaign.loaded_value_message_s_on_page_value_of_value.febbeb1e", { value0: jobs.length, value1: result.page, value2: result.pages || 1 })); + const result = await loadAllReviewJobs(version.id, showAllReviewJobs); + applyLoadedReviewJobs(result, silent); } catch (err) { if (!silent) setMessage(""); setError(err instanceof Error ? err.message : String(err)); @@ -643,6 +683,54 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS } } + function applyLoadedReviewJobs(result: CampaignJobsResponse, silent: boolean) { + if (!version) return; + const reviewKey = reviewJobsLoadKey(version.id, showAllReviewJobs); + const jobs = result.jobs.map(asRecord); + const mergedResult = { + ...result, + jobs, + page: 1, + page_size: jobs.length || result.page_size, + pages: jobs.length > 0 ? 1 : 0 + }; + reviewJobsRef.current = mergedResult; + setReviewJobs(mergedResult); + setBuiltReviewRows(jobs); + setReviewPage(1); + setJobsLoadedKey(reviewKey); + if (!silent) { + setMessage(i18nMessage("i18n:govoplan-campaign.loaded_value_message_s_on_page_value_of_value.febbeb1e", { value0: jobs.length, value1: 1, value2: 1 })); + } + } + + async function loadAllReviewJobs(versionId: string, includeAll: boolean): Promise { + const pageSize = 200; + const first = await getCampaignJobs(settings, campaignId, { + versionId, + page: 1, + pageSize, + validationStatus: includeAll ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES + }); + const allJobs = [...first.jobs]; + for (let page = 2; page <= Math.max(1, first.pages || 1); page += 1) { + const next = await getCampaignJobs(settings, campaignId, { + versionId, + page, + pageSize, + validationStatus: includeAll ? undefined : MESSAGE_REVIEW_ISSUE_STATUSES + }); + allJobs.push(...next.jobs); + } + return { + ...first, + jobs: allJobs, + page: 1, + page_size: pageSize, + pages: first.pages + }; + } + async function runMockSend() { if (!version || busy || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return; setBusy("mock"); @@ -788,7 +876,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS setError(""); setMessage("i18n:govoplan-campaign.recording_the_completed_message_review.16f58b81"); try { - const reviewed = new Set(reviewedMessageKeys); + const reviewed = new Set(reviewedMessageKeys); await updateCampaignReviewState(settings, campaignId, version.id, { inspection_complete: true, reviewed_message_keys: [...reviewed] @@ -833,20 +921,27 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS } } - async function openBuiltMessage(index: number) { - const row = builtReviewRows[index]; + async function openBuiltMessageAtIndex(index: number) { + const row = filteredBuiltReviewRows[index]; if (!row) return; - const jobId = String(row.id ?? ""); - const reviewKey = String(row.review_key ?? builtMessageKey(row, index)); + await openBuiltMessage(row, index); + } + + async function openBuiltMessage(row: Record, requestedIndex?: number) { + const index = requestedIndex ?? findBuiltMessageIndex(filteredBuiltReviewRows, row); + if (index < 0) return; + const reviewRow = filteredBuiltReviewRows[index] ?? row; + const jobId = String(reviewRow.id ?? ""); + const reviewKey = String(reviewRow.review_key ?? builtMessageKey(reviewRow, index)); setReviewedMessageKeys((current) => { const next = new Set(current); next.add(reviewKey); return next; }); - if (messageNeedsExplicitReview(row) && row.reviewed !== true) { + if (messageNeedsExplicitReview(reviewRow) && reviewRow.reviewed !== true) { setNewlyReviewedRequiredKeys((current) => new Set(current).add(reviewKey)); } - if (!jobId || row.resolved_recipients || row.attachments || row.issues) { + if (!jobId || reviewRow.resolved_recipients || reviewRow.attachments || reviewRow.issues) { setSelectedBuiltIndex(index); return; } @@ -854,7 +949,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS setError(""); try { const detail = await getCampaignJobDetail(settings, campaignId, jobId); - setBuiltReviewRows((current) => current.map((item, itemIndex) => itemIndex === index ? + setBuiltReviewRows((current) => current.map((item, itemIndex) => sameBuiltMessage(item, itemIndex, reviewRow, index) ? { ...item, ...detail.job, review_key: item.review_key, reviewed: item.reviewed, attempts: detail.attempts } : item)); setSelectedBuiltIndex(index); @@ -865,6 +960,52 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS } } + async function sendSingleBuiltMessage() { + if (!version || busy || singleSendConfirmIndex === null) return; + const row = filteredBuiltReviewRows[singleSendConfirmIndex]; + const jobId = String(row?.id ?? ""); + if (!row || !jobId) { + setSingleSendConfirmIndex(null); + setError("Selected message has no delivery job id."); + return; + } + setBusy("send"); + setMessage("Sending one selected message."); + setError(""); + try { + const response = await sendCampaignJob(settings, campaignId, jobId, { + include_warnings: true, + dry_run: false, + use_rate_limit: true, + enqueue_imap_task: backgroundWorkersEnabled + }); + const actionResult = asRecord(response.result ?? response); + const sendResult = asRecord(actionResult.result); + const status = String(sendResult.status ?? "submitted"); + const attemptCount = Number(sendResult.attempt_number ?? row.attempt_count ?? 0); + setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ? + { + ...item, + send_status: status === "already_accepted" ? "smtp_accepted" : status, + queue_status: ["smtp_accepted", "already_accepted"].includes(status) ? "draft" : item.queue_status, + imap_status: sendResult.imap_status ?? item.imap_status, + attempt_count: Number.isFinite(attemptCount) ? attemptCount : item.attempt_count, + last_error: sendResult.message ?? "" + } : + item)); + setMessage(`Single message send finished: ${humanize(status)}.`); + setSingleSendConfirmIndex(null); + setSelectedBuiltIndex(null); + await refreshQueueStatus(true); + await reload(); + } catch (err) { + setMessage(""); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(""); + } + } + function resetDownstreamReview() { setMessageReviewComplete(false); setBuiltReviewRows([]); @@ -875,6 +1016,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS setReviewedMessageKeys(new Set()); setNewlyReviewedRequiredKeys(new Set()); setSelectedBuiltIndex(null); + setSingleSendConfirmIndex(null); setMockResult(null); setSelectedMockMessage(null); resetDeltaWatermark(); @@ -937,7 +1079,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS - + @@ -960,7 +1102,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
- +
@@ -974,6 +1116,15 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS {validationErrors > 0 &&

i18n:govoplan-campaign.resolve_the_blocking_entries_then_validate_again.e282ae0f

} + void reloadAttachmentPreview(false)} + onLink={() => void linkMatchedAttachmentFiles()} /> + {visibleValidationIssues.length > 0 &&

i18n:govoplan-campaign.validation_details.aa503267

@@ -996,7 +1147,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
- +
@@ -1022,7 +1173,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS - @@ -1050,28 +1201,40 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS {showAllReviewJobs ? "i18n:govoplan-campaign.show_review_candidates_only.f49df60b" : "i18n:govoplan-campaign.show_all_messages.1c2107a1"}
- {reviewJobs.total} i18n:govoplan-campaign.matching_of.66a3778e {reviewJobs.total_unfiltered} i18n:govoplan-campaign.built_job_s.82d49ade + {filteredBuiltReviewRows.length} i18n:govoplan-campaign.matching_of.66a3778e {reviewJobs.total_unfiltered} i18n:govoplan-campaign.built_job_s.82d49ade
- -
- - i18n:govoplan-campaign.page.fb06270f {reviewJobs.pages === 0 ? 0 : reviewJobs.page} of {reviewJobs.pages} - -
+ className="data-table-wrap data-table compact-table" + pagination={{ + mode: "server", + page: effectiveReviewPage, + pageSize: reviewPageSize, + totalRows: filteredBuiltReviewRows.length, + pageSizeOptions: [25, 50, 100, 200], + disabled: busy === "inspect", + onPageChange: setReviewPage, + onPageSizeChange: (pageSize) => { + setReviewPageSize(pageSize); + setReviewPage(1); + } + }} + onQueryChange={(query) => { + if (reviewQueryEquals(reviewQuery, query)) return; + setReviewQuery(query); + setReviewPage(1); + }} />

i18n:govoplan-campaign.ready_messages_are_hidden_initially_messages_mar.1cb9770d

} - +
@@ -1128,7 +1291,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS } - +
i18n:govoplan-campaign.recipients.78cbf8eb{jobsTotal || "—"}
i18n:govoplan-campaign.messages_to_send.f7763bf9{builtCount || jobsTotal || "—"}
@@ -1279,13 +1442,27 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS setSingleSendConfirmIndex(targetIndex)} onClose={() => setSelectedBuiltIndex(null)} /> } + void runValidation(true)} + onCancel={() => setAttachmentLockConfirmOpen(false)} /> + + setSendConfirmOpen(false)} onConfirm={() => void runSendNow()} /> + setSingleSendConfirmIndex(null)} + onConfirm={() => void sendSingleBuiltMessage()} /> {selectedDeliveryJobDetail && setSelectedDeliveryJobDetail(null)} /> @@ -1337,9 +1524,15 @@ function WorkflowNavigation({ stages, onSelect }: {stages: FlowStageDefinition[] {stages.map((stage, index) => { const Icon = stage.icon; const nextStage = stages[index + 1]; + const connectorState = stageConnectorState(stage); + const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState; + const title = i18nMessage(stage.title); + const shortTitle = i18nMessage(stage.shortTitle); + const stateText = i18nMessage(stage.stateLabel); const style = { "--review-nav-color": stateColors[stage.state], - "--review-nav-next-color": stateColors[nextStage?.state ?? stage.state] + "--review-nav-line-color": stateColors[connectorState], + "--review-nav-line-next-color": stateColors[nextConnectorState] } as CSSProperties; const showSecondaryState = !["active", "locked"].includes(stage.state); return ( @@ -1349,15 +1542,15 @@ function WorkflowNavigation({ stages, onSelect }: {stages: FlowStageDefinition[] className="review-flow-navigation-item" data-state={stage.state} onClick={() => onSelect(stage.id)} - title={`${stage.title}: ${stage.stateLabel}`}> + title={`${title}: ${stateText}`}> - {stage.shortTitle} + {shortTitle} {stage.state === "locked" && } - {showSecondaryState && {stage.stateLabel}} + {showSecondaryState && {stateText}} {nextStage &&
+
+ 0 ? + : + loading ? "..." : matchedCount || "—"} /> + + + +
+ {error &&

{error}

} + {!error && unlinkedCount > 0 && +

i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998

+ } + {!error && !loading && matchedFiles.length === 0 && +

i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c

+ } + {matchedFiles.length > 0 && + + } +
+ setDetailsOpen(false)} + footer={}> +

i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5

+ +
+ ); +} + +function AttachmentLinkingFileList({ files, compact = false }: {files: CampaignAttachmentPreviewFile[];compact?: boolean;}) { + return ( +
    + {files.map((file, index) => { + const linked = file.linked_to_campaign !== false; + const Icon = linked ? Link2 : X; + return ( +
  • +
  • + ); + })} +
); +} + function DeliverabilityPreflight({ items }: {items: DeliverabilityPreflightItem[];}) { return (
@@ -1464,7 +1779,10 @@ function BuiltMessagePreview({ entries, rows, index, + canStartSingleMessageSend, + singleMessageSendBusy, onSelect, + onSendSingle, onClose @@ -1473,7 +1791,7 @@ function BuiltMessagePreview({ -}: {campaignJson: Record;entries: Record[];rows: Record[];index: number;onSelect: (index: number) => void;onClose: () => void;}) { +}: {campaignJson: Record;entries: Record[];rows: Record[];index: number;canStartSingleMessageSend: boolean;singleMessageSendBusy: boolean;onSelect: (index: number) => void;onSendSingle: (index: number) => void;onClose: () => void;}) { const row = rows[index] ?? {}; const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1); const entry = entries[entryIndex] ?? {}; @@ -1485,6 +1803,7 @@ function BuiltMessagePreview({ const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "i18n:govoplan-campaign.no_subject.7b4e8035"); const issues = asArray(row.issues).map(asRecord); const resolvedRecipients = asRecord(row.resolved_recipients); + const singleSendDisabledReason = singleMessageSendDisabledReason(row, canStartSingleMessageSend); return ( onSelect(Math.min(rows.length - 1, index + 1)), onLast: () => onSelect(rows.length - 1) }} + actions={ + + } onClose={onClose} />); } +function singleMessageSendDisabledReason(row: Record, canStartSingleMessageSend: boolean): string { + if (!canStartSingleMessageSend) { + return "Validate, build, and complete the required review/mock gate before sending individual messages."; + } + const jobId = String(row.id ?? ""); + if (!jobId) return "This preview row has no delivery job id."; + const buildStatus = String(row.build_status ?? ""); + if (buildStatus !== "built") return "This message has not been built."; + const validationStatus = String(row.validation_status ?? ""); + if (["blocked", "excluded", "inactive"].includes(validationStatus)) { + return `This message is ${humanize(validationStatus)}.`; + } + const sendStatus = String(row.send_status ?? "not_queued"); + const queueStatus = String(row.queue_status ?? "draft"); + if (["smtp_accepted", "sent"].includes(sendStatus)) return "This message was already accepted by SMTP."; + if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) return `This message is in delivery state ${humanize(sendStatus)}.`; + if (["failed_temporary", "failed_permanent"].includes(sendStatus)) return "Use the explicit retry action for failed messages."; + if (sendStatus === "queued" && queueStatus === "queued") return ""; + if (["not_queued", "cancelled"].includes(sendStatus) && ["draft", "cancelled"].includes(queueStatus)) return ""; + return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`; +} + function DeliveryJobDetailOverlay({ detail, onClose }: {detail: Record;onClose: () => void;}) { const job = asRecord(detail.job); const attempts = asRecord(detail.attempts); @@ -1561,7 +1911,7 @@ function AttemptList({ title, rows, emptyText = "i18n:govoplan-campaign.no_rows. } -function WorkflowFact({ label, value }: {label: string;value: React.ReactNode;}) { +function WorkflowFact({ label, value }: {label: string;value: ReactNode;}) { return (
{label} @@ -1571,7 +1921,7 @@ function WorkflowFact({ label, value }: {label: string;value: React.ReactNode;}) } function builtMessageColumns( -openMessage: (index: number) => void, +openMessage: (row: Record) => void, reviewedKeys: Set) : DataGridColumn>[] { return [ @@ -1590,10 +1940,148 @@ reviewedKeys: Set) }, { id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) }, { id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? : , value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" }, - { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (_row, index) => }]; + { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => }]; } +type ReviewFilterType = "text" | "integer" | "list"; +type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte"; + +function filterAndSortBuiltMessageRows( +rows: Record[], +query: DataGridQueryState, +reviewedKeys: Set) +: Record[] { + const filters = query.filters ?? {}; + const filtered = rows.filter((row, rowIndex) => + Object.entries(filters).every(([columnId, filterValue]) => { + if (!isBuiltMessageQueryColumn(columnId)) return true; + if (!filterValue.trim()) return true; + return matchesReviewFilter( + builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys), + filterValue, + builtMessageFilterType(columnId) + ); + }) + ); + if (!query.sort) return filtered; + const { columnId, direction } = query.sort; + if (!isBuiltMessageQueryColumn(columnId)) return filtered; + return [...filtered].sort((left, right) => { + const leftIndex = rows.indexOf(left); + const rightIndex = rows.indexOf(right); + const result = compareReviewValues( + builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys), + builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys) + ); + return direction === "desc" ? -result : result; + }); +} + +function isBuiltMessageQueryColumn(columnId: string): boolean { + return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId); +} + +function builtMessageColumnValue( +columnId: string, +row: Record, +index: number, +reviewedKeys: Set) +: unknown { + switch (columnId) { + case "number":return Number(row.entry_index ?? index + 1); + case "recipient":return formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—"); + case "subject":return String(row.subject ?? "—"); + case "validation":return String(row.validation_status ?? "unknown"); + case "attachments":return Number(row.attachment_count ?? countResolvedAttachments(row.attachments)); + case "reviewed":return row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no"; + default:return ""; + } +} + +function builtMessageFilterType(columnId: string): ReviewFilterType { + if (columnId === "number" || columnId === "attachments") return "integer"; + if (columnId === "validation" || columnId === "reviewed") return "list"; + return "text"; +} + +function matchesReviewFilter(value: unknown, filterValue: string, filterType: ReviewFilterType): boolean { + if (!filterValue.trim()) return true; + if (filterType === "list") { + const selected = parseReviewListFilter(filterValue); + return selected.includes(stringifyReviewCell(value)); + } + if (filterType === "integer") { + const parsed = parseReviewTypedFilter(filterValue); + if (!parsed.value.trim()) return true; + const actual = parseReviewNumber(value); + const expected = Number(parsed.value); + if (!Number.isFinite(actual) || !Number.isFinite(expected)) return false; + return compareReviewByOperator(actual, expected, parsed.operator); + } + return stringifyReviewCell(value).toLowerCase().includes(filterValue.trim().toLowerCase()); +} + +function parseReviewListFilter(value: string): string[] { + if (value.startsWith("list:")) { + try { + const parsed = JSON.parse(value.slice(5)); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; + } catch { + return []; + } + } + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +function parseReviewTypedFilter(value: string): {operator: ReviewFilterOperator;value: string;} { + if (!value.includes(":")) return { operator: "eq", value }; + const [operator, ...parts] = value.split(":"); + if (operator === "contains" || operator === "eq" || operator === "gt" || operator === "gte" || operator === "lt" || operator === "lte") { + return { operator, value: parts.join(":") }; + } + return { operator: "eq", value }; +} + +function parseReviewNumber(value: unknown): number { + if (typeof value === "number") return value; + const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", "."); + if (!text.trim()) return NaN; + return Number(text); +} + +function compareReviewByOperator(actual: number, expected: number, operator: ReviewFilterOperator): boolean { + if (operator === "gt") return actual > expected; + if (operator === "gte") return actual >= expected; + if (operator === "lt") return actual < expected; + if (operator === "lte") return actual <= expected; + return actual === expected; +} + +function compareReviewValues(left: unknown, right: unknown): number { + if (typeof left === "number" && typeof right === "number") return left - right; + return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, { numeric: true, sensitivity: "base" }); +} + +function stringifyReviewCell(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", "); + return ""; +} + +function reviewQueryEquals(left: DataGridQueryState, right: DataGridQueryState): boolean { + if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false; + if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false; + const leftFilters = left.filters ?? {}; + const rightFilters = right.filters ?? {}; + const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]); + for (const key of keys) { + if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false; + } + return true; +} + function mockSendResultColumns(): DataGridColumn>[] { const options: DataGridListOption[] = [ { value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" }, @@ -1709,10 +2197,10 @@ function builtMessageAttachments(row: Record): CampaignMessageP detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""), contentType: stringOrUndefined(attachment.content_type), sizeBytes: numberOrUndefined(attachment.size_bytes), - archiveGroup: stringOrUndefined(attachment.zip_filename), - archiveLabel: stringOrUndefined(attachment.zip_filename), - protected: zipProtection.protected, - protectionNote: zipProtection.note + archiveGroup: null, + archiveLabel: null, + protected: false, + protectionNote: null }]; }); } @@ -1767,17 +2255,17 @@ function countResolvedAttachments(value: unknown): number { for (const item of asArray(value)) { const attachment = asRecord(item); const zipFilename = String(attachment.zip_filename ?? "").trim(); - if (zipFilename) { + const managedCount = asArray(attachment.managed_matches).length; + const matchCount = asArray(attachment.matches).length; + if (zipFilename && (managedCount > 0 || matchCount > 0)) { archives.add(zipFilename); continue; } - const managedCount = asArray(attachment.managed_matches).length; if (managedCount > 0) { directCount += managedCount; continue; } - const matchCount = asArray(attachment.matches).length; - directCount += matchCount > 0 ? matchCount : 1; + directCount += matchCount; } return directCount + archives.size; } @@ -1808,6 +2296,43 @@ function builtMessageKey(row: Record, index: number): string { return String(row.entry_id ?? row.entry_index ?? index); } +function findBuiltMessageIndex(rows: Record[], row: Record): number { + const id = String(row.id ?? "").trim(); + if (id) { + const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id); + if (index >= 0) return index; + } + const reviewKey = String(row.review_key ?? "").trim(); + if (reviewKey) { + const index = rows.findIndex((candidate) => String(candidate.review_key ?? "").trim() === reviewKey); + if (index >= 0) return index; + } + const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim(); + if (entryKey) { + const index = rows.findIndex((candidate) => String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey); + if (index >= 0) return index; + } + return rows.indexOf(row); +} + +function sameBuiltMessage( +left: Record, +leftIndex: number, +right: Record, +rightIndex: number) +: boolean { + const leftId = String(left.id ?? "").trim(); + const rightId = String(right.id ?? "").trim(); + if (leftId && rightId) return leftId === rightId; + const leftReviewKey = String(left.review_key ?? "").trim(); + const rightReviewKey = String(right.review_key ?? "").trim(); + if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey; + const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim(); + const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim(); + if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey; + return left === right || leftIndex === rightIndex; +} + function formatAddressList(value: unknown): string { return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", "); } diff --git a/webui/src/features/campaigns/TemplateDataPage.tsx b/webui/src/features/campaigns/TemplateDataPage.tsx index 2c27a28..199a184 100644 --- a/webui/src/features/campaigns/TemplateDataPage.tsx +++ b/webui/src/features/campaigns/TemplateDataPage.tsx @@ -39,7 +39,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap 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, @@ -121,6 +121,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap const handle = window.setTimeout(() => { void previewCampaignAttachments(settings, campaignId, version.id, { include_unmatched: false, + include_unlinked_candidates: true, campaign_json: campaignJsonForAttachmentPreview(displayDraft) }). then((response) => { @@ -233,7 +234,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
- +
@@ -399,45 +400,46 @@ draft: Record) : CampaignMessagePreviewAttachment[] { if (loading) { return [{ - filename: i18nMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"), - detail: i18nMessage("i18n:govoplan-campaign.managed_files_are_being_checked_for_this_recipie.489ecefd") + filename: "Resolving attachment patterns", + detail: "Managed files are being checked for this recipient preview." }]; } if (error) { - return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }]; + return [{ filename: "Attachment preview unavailable", detail: error }]; } return rules.flatMap((rule) => { const zipProtection = zipProtectionForRule(rule, draft); const detailParts = [ - rule.source === "global" ? i18nMessage("i18n:govoplan-campaign.global.5f1184f7") : i18nMessage("i18n:govoplan-campaign.recipient.90343260"), + rule.source === "global" ? "Global" : "Recipient", rule.label, - rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"), + rule.required ? "Required" : "Optional", rule.pattern]. filter(Boolean); const detail = detailParts.join(" · "); - const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7"); + const fallbackArchiveLabel = "Recipient attachments ZIP"; if (rule.matches.length > 0) { return rule.matches.map((match) => ({ filename: match.filename || match.display_path, label: rule.label, - detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: match.display_path ? ` · ${match.display_path}` : "" }), + detail: `${match.linked_to_campaign === false ? "Unlinked candidate" : "Linked"} · ${match.display_path ? `${detail} · ${match.display_path}` : detail}`, contentType: match.content_type, sizeBytes: match.size_bytes, + linkedToCampaign: match.linked_to_campaign !== false, archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null, archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null, protected: zipProtection.protected, protectionNote: zipProtection.note })); } - const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952"); + const pattern = rule.pattern || "attachment pattern"; return [{ - filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }), + filename: `No file matched ${pattern}`, label: rule.label, - detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: rule.status !== "ok" ? ` · ${rule.status}` : "" }), - archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null, - archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null, - protected: zipProtection.protected, - protectionNote: zipProtection.note + detail: rule.status !== "ok" ? `${detail} · ${rule.status}` : detail, + archiveGroup: null, + archiveLabel: null, + protected: false, + protectionNote: null }]; }); } diff --git a/webui/src/features/campaigns/components/MessagePreviewOverlay.tsx b/webui/src/features/campaigns/components/MessagePreviewOverlay.tsx index 6de02f5..a056232 100644 --- a/webui/src/features/campaigns/components/MessagePreviewOverlay.tsx +++ b/webui/src/features/campaigns/components/MessagePreviewOverlay.tsx @@ -36,6 +36,7 @@ export type CampaignMessagePreviewOverlayProps = { raw?: string | null; rawLabel?: string; navigation?: CampaignMessagePreviewNavigation; + actions?: ReactNode; closeLabel?: string; onClose: () => void; }; @@ -53,6 +54,7 @@ export default function CampaignMessagePreviewOverlay({ raw, rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4", navigation, + actions, closeLabel = "i18n:govoplan-campaign.close.bbfa773e", onClose }: CampaignMessagePreviewOverlayProps) { @@ -131,7 +133,10 @@ export default function CampaignMessagePreviewOverlay({ } -
+
+ {actions &&
{actions}
} + +
); @@ -146,4 +151,4 @@ function isEditableTarget(target: EventTarget | null): boolean { export type MessagePreviewAttachment = CampaignMessagePreviewAttachment; export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem; export type MessagePreviewNavigation = CampaignMessagePreviewNavigation; -export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps; \ No newline at end of file +export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps; diff --git a/webui/src/features/campaigns/utils/attachmentPreview.ts b/webui/src/features/campaigns/utils/attachmentPreview.ts new file mode 100644 index 0000000..c5d31a0 --- /dev/null +++ b/webui/src/features/campaigns/utils/attachmentPreview.ts @@ -0,0 +1,53 @@ +export type AttachmentPreviewFileLike = { + id: string; + display_path: string; + filename: string; + linked_to_campaign?: boolean; +}; + +export type AttachmentPreviewLike = { + rules: Array<{matches: T[]}>; + linkable_files?: T[]; +}; + +export function attachmentPreviewMatchedFiles( + preview: AttachmentPreviewLike | null +): T[] { + if (!preview) return []; + const byId = new Map(); + for (const rule of preview.rules) { + for (const file of rule.matches) { + const key = file.id || file.display_path; + if (!key) continue; + const existing = byId.get(key); + if (existing) { + byId.set(key, { + ...existing, + linked_to_campaign: existing.linked_to_campaign !== false || file.linked_to_campaign !== false + }); + } else { + byId.set(key, file); + } + } + } + return [...byId.values()].sort((left, right) => { + const linkOrder = Number(left.linked_to_campaign === false) - Number(right.linked_to_campaign === false); + if (linkOrder !== 0) return linkOrder; + return (left.display_path || left.filename).localeCompare(right.display_path || right.filename); + }); +} + +export function attachmentPreviewLinkableFiles( + preview: AttachmentPreviewLike | null +): T[] { + if (!preview) return []; + const source = preview.linkable_files && preview.linkable_files.length > 0 + ? preview.linkable_files + : attachmentPreviewMatchedFiles(preview).filter((file) => file.linked_to_campaign === false); + const byId = new Map(); + for (const file of source) { + const key = file.id || file.display_path; + if (key) byId.set(key, file); + } + return [...byId.values()]; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 7d17a3b..b3894fa 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -60,6 +60,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states", "i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states", "i18n:govoplan-campaign.all_templates.0bba114c": "All templates", + "i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5": "All unique managed files matched by the current attachment rules are available in this list.", "i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments", "i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC", "i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC", @@ -95,6 +96,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.", "i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV", "i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report", + "i18n:govoplan-campaign.attachment_file_links.0be74fd1": "Attachment file links", + "i18n:govoplan-campaign.attachment_file_links_value.ce230e30": "Attachment file links ({value0})", "i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues", "i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label", "i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice", @@ -105,6 +108,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Keep original ZIP entry name", "i18n:govoplan-campaign.message_filename_template.0b7ac934": "Message filename template", "i18n:govoplan-campaign.zip_entry_name_template.83772a73": "ZIP entry name template", + "i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d": "Attachment preview refreshed.", "i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable", "i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.", "i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),", @@ -430,8 +434,17 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}", "i18n:govoplan-campaign.last_message.83741110": "Last message", "i18n:govoplan-campaign.last_result.110b888b": "Last result", + "i18n:govoplan-campaign.link_and_lock.6eac996d": "Link and lock", + "i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653": "Link matched files before locking?", + "i18n:govoplan-campaign.link_value_file.4d4ce740": "Link {value0} file", "i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)", + "i18n:govoplan-campaign.link_value_files.88b7e6a7": "Link {value0} files", + "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc": "Linked: already part of the campaign file snapshot.", + "i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7": "Linked {value0} attachment file(s) to this campaign.", "i18n:govoplan-campaign.linked.a089f600": "Linked", + "i18n:govoplan-campaign.linking.a5f54e0f": "Linking", + "i18n:govoplan-campaign.linking_matched_attachment_files.92f38088": "Linking matched attachment files…", + "i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0": "Linking matched files, then validating the campaign…", "i18n:govoplan-campaign.linking.6f640897": "Linking…", "i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library", "i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics", @@ -483,6 +496,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.map.ab478f3e": "Map", "i18n:govoplan-campaign.mapping_value": "Mapping: {value0}", "i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.", + "i18n:govoplan-campaign.matched.1bf3ec5b": "Matched", "i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments", "i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files", "i18n:govoplan-campaign.matching_of.66a3778e": "matching of", @@ -541,6 +555,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.my_files.71d01a41": "My files", "i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario", "i18n:govoplan-campaign.name.709a2322": "Name", + "i18n:govoplan-campaign.need_link.fa4ab530": "Need link", "i18n:govoplan-campaign.need_linking.a7617722": "Need linking", "i18n:govoplan-campaign.need_review.201a4493": "Need review", "i18n:govoplan-campaign.needs_attention.a126722e": "Needs attention", @@ -588,6 +603,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source", "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.", "i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.", + "i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c": "No managed files matched the current attachment patterns.", "i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.", "i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID", "i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.", @@ -693,6 +709,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions", "i18n:govoplan-campaign.policies.f03ff937": "POLICIES", "i18n:govoplan-campaign.policy.bb9cf141": "Policy", + "i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af": "Preview includes already linked files and accessible unlinked candidates. Locking can link candidates before validation.", "i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation", "i18n:govoplan-campaign.preview.4bf30626": "Preview:", "i18n:govoplan-campaign.preview.f1fbb2b4": "Preview", @@ -757,6 +774,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches", "i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status", "i18n:govoplan-campaign.refresh.56e3badc": "Refresh", + "i18n:govoplan-campaign.refreshing.505dddc9": "Refreshing", "i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…", "i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…", "i18n:govoplan-campaign.reload_page.37614e96": "Reload page", @@ -886,6 +904,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.", "i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files", "i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list", + "i18n:govoplan-campaign.shared_source.7d4a1bf2": "Shared source", "i18n:govoplan-campaign.shared_with.6203f449": "Shared with", "i18n:govoplan-campaign.shared.50d0d8dd": "Shared", "i18n:govoplan-campaign.sheet.53bc47a7": "Sheet", @@ -1019,6 +1038,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference", "i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:", "i18n:govoplan-campaign.unknown.bc7819b3": "Unknown", + "i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998": "Unlinked candidate files are not yet part of the campaign snapshot. They will be linked when you confirm locking.", + "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433": "Unlinked: candidate match, potentially missing until linked.", "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?", "i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?", "i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation", @@ -1092,7 +1113,10 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)", "i18n:govoplan-campaign.value_encryption_value": "{value0} · Encryption: {value1}", "i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.", + "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824": "{value0} matched attachment file link", + "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a": "{value0} matched attachment file links", "i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.", + "i18n:govoplan-campaign.value_matched_file_s_are_not_yet_linked_to_t.43d6c926": "{value0} matched file(s) are not yet linked to this campaign. Link them now and continue locking?", "i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min", "i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute", "i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.", @@ -1111,6 +1135,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.version.2da600bf": "Version", "i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export", "i18n:govoplan-campaign.versions.a239107e": "Versions", + "i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951": "View all {value0} matched attachment file links", "i18n:govoplan-campaign.waiting.33d30632": "Waiting", "i18n:govoplan-campaign.warn.3009d557": "Warn", "i18n:govoplan-campaign.warning.e9c45563": "Warning", @@ -1187,6 +1212,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states", "i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states", "i18n:govoplan-campaign.all_templates.0bba114c": "All templates", + "i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5": "Diese Liste enthält alle eindeutigen verwalteten Dateien, die den aktuellen Anhangsregeln entsprechen.", "i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments", "i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC", "i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC", @@ -1222,6 +1248,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.", "i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV", "i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report", + "i18n:govoplan-campaign.attachment_file_links.0be74fd1": "Verknüpfungen von Anhangsdateien", + "i18n:govoplan-campaign.attachment_file_links_value.ce230e30": "Verknüpfungen von Anhangsdateien ({value0})", "i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues", "i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label", "i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice", @@ -1232,6 +1260,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Urspruenglichen ZIP-Eintragsnamen behalten", "i18n:govoplan-campaign.message_filename_template.0b7ac934": "Dateinamenvorlage fuer Nachrichten", "i18n:govoplan-campaign.zip_entry_name_template.83772a73": "Namensvorlage fuer ZIP-Eintraege", + "i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d": "Attachment preview refreshed.", "i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable", "i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.", "i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),", @@ -1557,8 +1586,17 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}", "i18n:govoplan-campaign.last_message.83741110": "Last message", "i18n:govoplan-campaign.last_result.110b888b": "Last result", + "i18n:govoplan-campaign.link_and_lock.6eac996d": "Link and lock", + "i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653": "Link matched files before locking?", + "i18n:govoplan-campaign.link_value_file.4d4ce740": "{value0} Datei verknüpfen", "i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)", - "i18n:govoplan-campaign.linked.a089f600": "Linked", + "i18n:govoplan-campaign.link_value_files.88b7e6a7": "{value0} Dateien verknüpfen", + "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc": "Verknüpft: bereits Teil des Kampagnen-Datei-Snapshots.", + "i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7": "Linked {value0} attachment file(s) to this campaign.", + "i18n:govoplan-campaign.linked.a089f600": "Verknüpft", + "i18n:govoplan-campaign.linking.a5f54e0f": "Wird verknüpft", + "i18n:govoplan-campaign.linking_matched_attachment_files.92f38088": "Linking matched attachment files…", + "i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0": "Linking matched files, then validating the campaign…", "i18n:govoplan-campaign.linking.6f640897": "Linking…", "i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library", "i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics", @@ -1610,6 +1648,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.map.ab478f3e": "Map", "i18n:govoplan-campaign.mapping_value": "Mapping: {value0}", "i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.", + "i18n:govoplan-campaign.matched.1bf3ec5b": "Treffer", "i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments", "i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files", "i18n:govoplan-campaign.matching_of.66a3778e": "matching of", @@ -1668,6 +1707,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.my_files.71d01a41": "My files", "i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario", "i18n:govoplan-campaign.name.709a2322": "Name", + "i18n:govoplan-campaign.need_link.fa4ab530": "Zu verknüpfen", "i18n:govoplan-campaign.need_linking.a7617722": "Need linking", "i18n:govoplan-campaign.need_review.201a4493": "Need review", "i18n:govoplan-campaign.needs_attention.a126722e": "Benötigt Aufmerksamkeit", @@ -1715,6 +1755,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source", "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.", "i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.", + "i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c": "Keine verwalteten Dateien entsprechen den aktuellen Anhangsmustern.", "i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.", "i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID", "i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.", @@ -1820,6 +1861,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions", "i18n:govoplan-campaign.policies.f03ff937": "POLICIES", "i18n:govoplan-campaign.policy.bb9cf141": "Richtlinie", + "i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af": "Die Vorschau enthält bereits verknüpfte Dateien und zugängliche, noch nicht verknüpfte Kandidaten. Beim Sperren können Kandidaten vor der Validierung verknüpft werden.", "i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation", "i18n:govoplan-campaign.preview.4bf30626": "Preview:", "i18n:govoplan-campaign.preview.f1fbb2b4": "Vorschau", @@ -1883,7 +1925,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.recording_the_completed_message_review.16f58b81": "Recording the completed message review…", "i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches", "i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status", - "i18n:govoplan-campaign.refresh.56e3badc": "Refresh", + "i18n:govoplan-campaign.refresh.56e3badc": "Aktualisieren", + "i18n:govoplan-campaign.refreshing.505dddc9": "Wird aktualisiert", "i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…", "i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…", "i18n:govoplan-campaign.reload_page.37614e96": "Reload page", @@ -2013,6 +2056,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.", "i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files", "i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list", + "i18n:govoplan-campaign.shared_source.7d4a1bf2": "Gemeinsame Quelle", "i18n:govoplan-campaign.shared_with.6203f449": "Freigegeben für", "i18n:govoplan-campaign.shared.50d0d8dd": "Shared", "i18n:govoplan-campaign.sheet.53bc47a7": "Sheet", @@ -2146,6 +2190,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference", "i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:", "i18n:govoplan-campaign.unknown.bc7819b3": "Unknown", + "i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998": "Nicht verknüpfte Kandidatendateien sind noch nicht Teil des Kampagnen-Snapshots. Sie werden verknüpft, wenn Sie das Sperren bestätigen.", + "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433": "Nicht verknüpft: möglicher Treffer, der ohne Verknüpfung fehlen kann.", "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?", "i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?", "i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation", @@ -2219,7 +2265,10 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)", "i18n:govoplan-campaign.value_encryption_value": "{value0} · Verschlüsselung: {value1}", "i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.", + "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824": "{value0} gefundene Verknüpfung einer Anhangsdatei", + "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a": "{value0} gefundene Verknüpfungen von Anhangsdateien", "i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.", + "i18n:govoplan-campaign.value_matched_file_s_are_not_yet_linked_to_t.43d6c926": "{value0} matched file(s) are not yet linked to this campaign. Link them now and continue locking?", "i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min", "i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute", "i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.", @@ -2238,6 +2287,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-campaign.version.2da600bf": "Version", "i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export", "i18n:govoplan-campaign.versions.a239107e": "Versions", + "i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951": "Alle {value0} gefundenen Verknüpfungen von Anhangsdateien anzeigen", "i18n:govoplan-campaign.waiting.33d30632": "Waiting", "i18n:govoplan-campaign.warn.3009d557": "Warn", "i18n:govoplan-campaign.warning.e9c45563": "Warning", diff --git a/webui/tests/review-preview-ui.test.ts b/webui/tests/review-preview-ui.test.ts new file mode 100644 index 0000000..8b17871 --- /dev/null +++ b/webui/tests/review-preview-ui.test.ts @@ -0,0 +1,113 @@ +import { + attachmentPreviewLinkableFiles, + attachmentPreviewMatchedFiles, + type AttachmentPreviewFileLike, + type AttachmentPreviewLike +} from "../src/features/campaigns/utils/attachmentPreview"; + +declare function require(name: string): { + readFileSync(path: string, encoding: string): string; + createHash(algorithm: string): { + update(value: string): {digest(encoding: "hex"): string}; + }; +}; + +const { readFileSync } = require("node:fs"); +const { createHash } = require("node:crypto"); + +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +function previewFile(index: number): AttachmentPreviewFileLike { + return { + id: `file-${index}`, + display_path: `/campaign/attachments/file-${String(index).padStart(2, "0")}.pdf`, + filename: `file-${String(index).padStart(2, "0")}.pdf`, + linked_to_campaign: index % 2 === 0 + }; +} + +function previewRule(matches: AttachmentPreviewFileLike[]): {matches: AttachmentPreviewFileLike[]} { + return { + matches + }; +} + +const files = Array.from({ length: 25 }, (_, index) => previewFile(index)); +const duplicatePromotedToLinked = { ...files[1], linked_to_campaign: true }; +const preview: AttachmentPreviewLike = { + rules: [ + previewRule(files.slice(0, 14)), + previewRule([...files.slice(14), duplicatePromotedToLinked]) + ] +}; + +const matched = attachmentPreviewMatchedFiles(preview); +assert(matched.length === 25, "all unique attachment matches remain available; the review model must not truncate at twelve"); +assert(matched.find((file) => file.id === "file-1")?.linked_to_campaign === true, "duplicate matches preserve the strongest linked state"); +assert(matched.every((file, index) => index === 0 || Number(matched[index - 1].linked_to_campaign === false) <= Number(file.linked_to_campaign === false)), "linked matches sort before unlinked candidates"); + +const fallbackLinkable = attachmentPreviewLinkableFiles(preview); +assert(fallbackLinkable.length === 11, "linkable fallback contains every unique unlinked candidate"); + +const explicitLinkablePreview = { + ...preview, + linkable_files: [files[3], files[3], files[5]] +}; +assert(attachmentPreviewLinkableFiles(explicitLinkablePreview).length === 2, "explicit linkable candidates are deduplicated without a display cap"); + +const reviewSource = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8"); +assert(!reviewSource.includes("attachmentPreviewMatchedFiles(preview).slice("), "review attachment links must not use an arbitrary display slice"); +assert(reviewSource.includes(""), "compact review exposes the complete bounded attachment list"); +assert(reviewSource.includes('aria-haspopup="dialog"'), "the matched count advertises its attachment detail dialog"); +assert(reviewSource.includes("tabIndex={0}"), "bounded attachment lists are keyboard-focusable scroll regions"); +assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30"'), "dynamic attachment dialog title uses the i18n message contract"); +assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951"'), "dynamic attachment count aria label uses the i18n message contract"); +assert(!reviewSource.includes(">Attachment file links<"), "attachment review heading is not raw English"); +assert(!reviewSource.includes('label="Matched"'), "attachment review facts are not raw English"); +assert(!reviewSource.includes("`Link ${unlinkedCount}"), "attachment link actions are not assembled from raw English"); +assert(!reviewSource.includes("Linked: already part of the campaign file snapshot."), "attachment link-state detail is not raw English"); +assert(!reviewSource.includes("Unlinked: candidate match, potentially missing until linked."), "unlinked attachment detail is not raw English"); + +const newTranslations = [ + ["i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5", "All unique managed files matched by the current attachment rules are available in this list.", "Diese Liste enthält alle eindeutigen verwalteten Dateien, die den aktuellen Anhangsregeln entsprechen."], + ["i18n:govoplan-campaign.attachment_file_links.0be74fd1", "Attachment file links", "Verknüpfungen von Anhangsdateien"], + ["i18n:govoplan-campaign.attachment_file_links_value.ce230e30", "Attachment file links ({value0})", "Verknüpfungen von Anhangsdateien ({value0})"], + ["i18n:govoplan-campaign.link_value_file.4d4ce740", "Link {value0} file", "{value0} Datei verknüpfen"], + ["i18n:govoplan-campaign.link_value_files.88b7e6a7", "Link {value0} files", "{value0} Dateien verknüpfen"], + ["i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc", "Linked: already part of the campaign file snapshot.", "Verknüpft: bereits Teil des Kampagnen-Datei-Snapshots."], + ["i18n:govoplan-campaign.linking.a5f54e0f", "Linking", "Wird verknüpft"], + ["i18n:govoplan-campaign.matched.1bf3ec5b", "Matched", "Treffer"], + ["i18n:govoplan-campaign.need_link.fa4ab530", "Need link", "Zu verknüpfen"], + ["i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c", "No managed files matched the current attachment patterns.", "Keine verwalteten Dateien entsprechen den aktuellen Anhangsmustern."], + ["i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af", "Preview includes already linked files and accessible unlinked candidates. Locking can link candidates before validation.", "Die Vorschau enthält bereits verknüpfte Dateien und zugängliche, noch nicht verknüpfte Kandidaten. Beim Sperren können Kandidaten vor der Validierung verknüpft werden."], + ["i18n:govoplan-campaign.refreshing.505dddc9", "Refreshing", "Wird aktualisiert"], + ["i18n:govoplan-campaign.shared_source.7d4a1bf2", "Shared source", "Gemeinsame Quelle"], + ["i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998", "Unlinked candidate files are not yet part of the campaign snapshot. They will be linked when you confirm locking.", "Nicht verknüpfte Kandidatendateien sind noch nicht Teil des Kampagnen-Snapshots. Sie werden verknüpft, wenn Sie das Sperren bestätigen."], + ["i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433", "Unlinked: candidate match, potentially missing until linked.", "Nicht verknüpft: möglicher Treffer, der ohne Verknüpfung fehlen kann."], + ["i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824", "{value0} matched attachment file link", "{value0} gefundene Verknüpfung einer Anhangsdatei"], + ["i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a", "{value0} matched attachment file links", "{value0} gefundene Verknüpfungen von Anhangsdateien"], + ["i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951", "View all {value0} matched attachment file links", "Alle {value0} gefundenen Verknüpfungen von Anhangsdateien anzeigen"] +] as const; + +const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8"); +for (const [key, english, german] of newTranslations) { + const digest = createHash("sha1").update(english).digest("hex").slice(0, 8); + assert(key.endsWith(`.${digest}`), `${key} uses the stable SHA-1 suffix for its English source`); + assert(translationCatalog.split(`"${key}"`).length - 1 === 2, `${key} is present exactly once in both language catalogs`); + assert(translationCatalog.includes(`"${key}": ${JSON.stringify(english)}`), `${key} has its English catalog value`); + assert(translationCatalog.includes(`"${key}": ${JSON.stringify(german)}`), `${key} has its German catalog value`); +} + +const overlaySource = readFileSync("src/features/campaigns/components/MessagePreviewOverlay.tsx", "utf8"); +assert(overlaySource.includes("message-preview-backdrop"), "message previews expose a layout-specific responsive backdrop hook"); +assert(overlaySource.includes("{actions &&