diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md
index b8a2cb6..7c6413c 100644
--- a/docs/CAMPAIGN_HANDBOOK.md
+++ b/docs/CAMPAIGN_HANDBOOK.md
@@ -141,6 +141,14 @@ just the authoring form:
content, recipients, attachment inputs, owner context, or non-secret
transport identity changes, revalidate and rebuild.
+The Review & Send surface separates three kinds of attention. Critical
+blockers must be corrected before delivery, individual review items require a
+recorded message decision, and non-critical group items may be acknowledged
+together after individual review is complete. Each warning or blocker names
+the required action, the responsible role, and the workspace to open. The
+review summary keeps reviewed and remaining counts visible; a completed review
+acknowledges the group items and remains bound to the current build token.
+
This evidence is the Campaign input to separation-of-duties policy. Generic
approve/reject chains, delegation, substitutions, escalation, and signatures
belong to the optional Approvals capability. Campaign must not claim an
diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py
index f8e334b..f051c52 100644
--- a/src/govoplan_campaign/backend/manifest.py
+++ b/src/govoplan_campaign/backend/manifest.py
@@ -881,8 +881,8 @@ manifest = ModuleManifest(
DocumentationTopic(
id="campaigns.workflow.complete-review",
title="Inspect built messages and complete review",
- summary="Review the exact immutable candidate and record which built messages were inspected before delivery is enabled.",
- body="Review completion records the inspected message keys for the selected build. The current baseline does not persist a separate approve/reject decision or review reason, so do not present completion as a richer decision record. Any material input or non-secret transport-identity change requires validation and a new build.",
+ summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
+ body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
layer="configured",
documentation_types=("user",),
audience=("campaign_reviewer",),
@@ -918,13 +918,14 @@ manifest = ModuleManifest(
"You may read the campaign and complete its review.",
],
"steps": [
- "Confirm the campaign, owner, selected version, recipient count, warnings, and exclusions.",
- "Inspect representative and exceptional messages, addressing, templates, and attachment evidence.",
- "Confirm that the selected Mail profile is suitable and authorized for the current context.",
- "Record review completion for the exact message keys inspected.",
+ "Open Review and send and inspect the Critical blockers, Individual review, and Group review summaries.",
+ "Correct every critical blocker in the named campaign workspace, then validate and build again.",
+ "Open each remaining individual review message and record its decision.",
+ "When only non-critical group items remain, review their conditions and explicitly complete review.",
+ "Confirm that Reviewed equals the required review total and Remaining is zero before delivery.",
],
- "outcome": "The reviewed build is eligible for a separately authorized queue or send action.",
- "verification": "Reload Review and confirm completion is tied to the same version and message build; changed inputs must invalidate or supersede it.",
+ "outcome": "Review evidence for the exact current build, with no unresolved blocker or review decision.",
+ "verification": "Reload Review and send, confirm no critical blocker or remaining decision, and verify that the permitted delivery mode is unlocked for the same version and build.",
"related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.retry-and-reconcile",
diff --git a/tests/test_documentation.py b/tests/test_documentation.py
index f62dd9e..16a35be 100644
--- a/tests/test_documentation.py
+++ b/tests/test_documentation.py
@@ -54,6 +54,30 @@ def test_campaign_runtime_documentation_provider_is_registered() -> None:
assert documentation_topics in get_manifest().documentation_providers
+def test_complete_review_workflow_documents_each_attention_class() -> None:
+ from govoplan_campaign.backend.manifest import get_manifest
+
+ topic = next(
+ item
+ for item in get_manifest().documentation
+ if item.id == "campaigns.workflow.complete-review"
+ )
+
+ rendered = "\n".join(
+ (
+ topic.summary,
+ topic.body,
+ *topic.metadata["steps"],
+ topic.metadata["verification"],
+ )
+ )
+ assert "Critical blockers" in rendered
+ assert "Individual review" in rendered
+ assert "Group review" in rendered
+ assert "Remaining is zero" in rendered
+ assert topic.metadata["help_contexts"] == ["campaign.review-send"]
+
+
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
assert _topics({"docs:documentation:read"}) == ()
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
diff --git a/webui/package.json b/webui/package.json
index 0f531e8..3e31776 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -30,6 +30,7 @@
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.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 && node tests/delivery-mode-ui-structure.test.mjs",
+ "test:review-workflow": "node --experimental-strip-types --test tests/review-workflow-guidance.test.ts && node tests/review-workflow-guidance-ui-structure.test.mjs",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx
index 0a2f91b..ed6f373 100644
--- a/webui/src/features/campaigns/ReviewSendPage.tsx
+++ b/webui/src/features/campaigns/ReviewSendPage.tsx
@@ -72,6 +72,12 @@ import DeliverabilityPreflight, {
} from "./review/DeliverabilityPreflight";
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
import BuiltMessagePreview from "./review/BuiltMessagePreview";
+import {
+ BuiltMessageReviewProgress,
+ BuiltMessageWorkflowGuidance,
+ ValidationWorkflowGuidance
+} from "./review/ReviewWorkflowGuidance";
+import { calculateBuildReviewProgress } from "./review/reviewProgress";
import {
WorkflowFact,
WorkflowNavigation,
@@ -466,7 +472,6 @@ export default function ReviewSendPage({
Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size
);
const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount;
- const reviewedRequiredCount = reviewedExplicitCount;
const automaticInspectionComplete = reviewJobs.total_unfiltered > 0 &&
blockingReviewCount === 0 &&
reviewRequiredCount === 0;
@@ -505,6 +510,13 @@ export default function ReviewSendPage({
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
+ const buildReviewProgress = calculateBuildReviewProgress({
+ blocking: Math.max(blockingReviewCount, buildBlocked),
+ individualRequired: explicitReviewCount,
+ individualReviewed: reviewedExplicitCount,
+ groupRequired: bulkAcceptableCount,
+ reviewComplete: messageReviewComplete || downstreamDeliveryActivity
+ });
const buildReviewState: FlowState = !readyForDelivery ?
"locked" :
@@ -512,7 +524,7 @@ export default function ReviewSendPage({
"running" :
hasBuild && buildBlocked > 0 ?
"danger" :
- hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied) ?
+ hasBuild && !inspectionSatisfied && (buildNeedsReview > 0 || buildWarnings > 0 || buildReviewProgress.remaining > 0) ?
"warning" :
hasBuild && inspectionSatisfied ?
"complete" :
@@ -1392,13 +1404,14 @@ export default function ReviewSendPage({
- {validationStale &&
i18n:govoplan-campaign.the_stored_validation_result_is_no_longer_an_act.45d2dd14
}
+
{validationPresent && validationErrors === 0 &&
i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d
}
- {validationErrors > 0 &&
- i18n:govoplan-campaign.resolve_the_blocking_entries_then_validate_again.e282ae0f
- }
-
+
+
+ {hasBuild && }
+ {hasBuild && }
i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170
{getText(printOutput, "render_id") && (
@@ -1501,9 +1517,6 @@ export default function ReviewSendPage({
{automaticInspectionComplete &&
i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791
}
- {blockingReviewCount > 0 &&
-
{blockingReviewCount} i18n:govoplan-campaign.blocked_or_failed_message_s_must_be_resolved_bef.5c6b5140
- }
{hasBuild &&
diff --git a/webui/src/features/campaigns/review/ReviewWorkflowGuidance.tsx b/webui/src/features/campaigns/review/ReviewWorkflowGuidance.tsx
new file mode 100644
index 0000000..e87dd0d
--- /dev/null
+++ b/webui/src/features/campaigns/review/ReviewWorkflowGuidance.tsx
@@ -0,0 +1,161 @@
+import {
+ ActionBlockerHint,
+ GuidedReviewList,
+ i18nMessage,
+ type ActionBlockerReason
+} from "@govoplan/core-webui";
+
+import type { BuildReviewProgress } from "./reviewProgress";
+
+type InterventionHintProps = {
+ tone: "warning" | "danger";
+ summary: string;
+ requiredAction: string;
+ destination: string;
+};
+
+function InterventionHint({ tone, summary, requiredAction, destination }: InterventionHintProps) {
+ const requiredActionLabel = i18nMessage("i18n:govoplan-campaign.required_action.f2429497");
+ const actorLabel = i18nMessage("i18n:govoplan-campaign.who_can_resolve_this.e60939b2");
+ const destinationLabel = i18nMessage("i18n:govoplan-campaign.where_to_go.bb1c6969");
+ const actor = i18nMessage("i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106");
+ const reason: ActionBlockerReason = {
+ summary,
+ requiredAction,
+ actor,
+ target: destination
+ };
+
+ return (
+
+ );
+}
+
+export function ValidationWorkflowGuidance({
+ errors,
+ warnings,
+ stale
+}: {
+ errors: number;
+ warnings: number;
+ stale: boolean;
+}) {
+ return (
+
+ {errors > 0 && (
+
+ )}
+ {stale && (
+
+ )}
+ {warnings > 0 && (
+
+ )}
+
+ );
+}
+
+export function BuiltMessageReviewProgress({ progress }: { progress: BuildReviewProgress }) {
+ const blockerDetail = progress.blocking > 0
+ ? i18nMessage("i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b")
+ : i18nMessage("i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f");
+ const individualDetail = progress.individualRemaining > 0
+ ? i18nMessage("i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9")
+ : i18nMessage("i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc");
+ const groupDetail = progress.groupRemaining > 0
+ ? i18nMessage("i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f")
+ : progress.groupRequired > 0
+ ? i18nMessage("i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6")
+ : i18nMessage("i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc");
+
+ return (
+
0 ? "danger" : "success"
+ },
+ {
+ label: i18nMessage("i18n:govoplan-campaign.individual_review.402783bf"),
+ value: `${progress.individualReviewed} / ${progress.individualRequired}`,
+ detail: individualDetail,
+ tone: progress.individualRemaining > 0 ? "warning" : "success"
+ },
+ {
+ label: i18nMessage("i18n:govoplan-campaign.group_review.a809a9d9"),
+ value: `${progress.groupReviewed} / ${progress.groupRequired}`,
+ detail: groupDetail,
+ tone: progress.groupRemaining > 0 ? "warning" : "success"
+ }
+ ]}
+ />
+ );
+}
+
+export function BuiltMessageWorkflowGuidance({
+ progress,
+ buildWarnings
+}: {
+ progress: BuildReviewProgress;
+ buildWarnings: number;
+}) {
+ const showUnacknowledgedWarning = progress.remaining === 0 && buildWarnings > 0;
+ return (
+
+ {progress.blocking > 0 && (
+
+ )}
+ {progress.remaining > 0 && (
+
+ )}
+ {showUnacknowledgedWarning && (
+
+ )}
+
+ );
+}
diff --git a/webui/src/features/campaigns/review/reviewProgress.ts b/webui/src/features/campaigns/review/reviewProgress.ts
new file mode 100644
index 0000000..a30949e
--- /dev/null
+++ b/webui/src/features/campaigns/review/reviewProgress.ts
@@ -0,0 +1,49 @@
+export type BuildReviewProgressInput = {
+ blocking: number;
+ individualRequired: number;
+ individualReviewed: number;
+ groupRequired: number;
+ reviewComplete: boolean;
+};
+
+export type BuildReviewProgress = {
+ blocking: number;
+ individualRequired: number;
+ individualReviewed: number;
+ individualRemaining: number;
+ groupRequired: number;
+ groupReviewed: number;
+ groupRemaining: number;
+ required: number;
+ reviewed: number;
+ remaining: number;
+};
+
+function count(value: number): number {
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
+}
+
+export function calculateBuildReviewProgress(input: BuildReviewProgressInput): BuildReviewProgress {
+ const blocking = count(input.blocking);
+ const individualRequired = count(input.individualRequired);
+ const groupRequired = count(input.groupRequired);
+ const individualReviewed = input.reviewComplete
+ ? individualRequired
+ : Math.min(individualRequired, count(input.individualReviewed));
+ const groupReviewed = input.reviewComplete ? groupRequired : 0;
+ const individualRemaining = individualRequired - individualReviewed;
+ const groupRemaining = groupRequired - groupReviewed;
+
+ return {
+ blocking,
+ individualRequired,
+ individualReviewed,
+ individualRemaining,
+ groupRequired,
+ groupReviewed,
+ groupRemaining,
+ required: individualRequired + groupRequired,
+ reviewed: individualReviewed + groupReviewed,
+ remaining: individualRemaining + groupRemaining
+ };
+}
diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts
index 7bcfdfc..bf7cad0 100644
--- a/webui/src/i18n/generatedTranslations.ts
+++ b/webui/src/i18n/generatedTranslations.ts
@@ -4,6 +4,35 @@ export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-campaign.guided_workflows": "Guided workflows",
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.",
+ "i18n:govoplan-campaign.required_action.f2429497": "Required action",
+ "i18n:govoplan-campaign.who_can_resolve_this.e60939b2": "Who can resolve this",
+ "i18n:govoplan-campaign.where_to_go.bb1c6969": "Where to go",
+ "i18n:govoplan-campaign.critical_blockers.8a37e088": "Critical blockers",
+ "i18n:govoplan-campaign.remaining.cc632b5e": "Remaining",
+ "i18n:govoplan-campaign.individual_review.402783bf": "Individual review",
+ "i18n:govoplan-campaign.group_review.a809a9d9": "Group review",
+ "i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blocking validation issue(s) prevent building.",
+ "i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Correct the affected campaign data, then validate again.",
+ "i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0": "Campaign identity, Sender & Recipients, Template, or Files",
+ "i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106": "Campaign editor or reviewer",
+ "i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1": "{value0} validation warning(s) need review but do not block building.",
+ "i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4": "Inspect the warning details; correct the data or accept the documented condition.",
+ "i18n:govoplan-campaign.review_send_validate.c1b403c7": "Review & Send - Validate",
+ "i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930": "The validation evidence is stale.",
+ "i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9": "Run validation again before relying on this result.",
+ "i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60": "{value0} built message(s) are blocked or failed.",
+ "i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Correct the affected recipient, template, mail, or attachment data and rebuild.",
+ "i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Sender & Recipients, Template, Mail settings, or Files",
+ "i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} review decision(s) remain ({value1} individual, {value2} group).",
+ "i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Open every critical message and record a decision; then explicitly accept any non-critical group.",
+ "i18n:govoplan-campaign.review_send_built_messages.6b030946": "Review & Send - Built messages",
+ "i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} built message warning(s) need acknowledgement.",
+ "i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Must be corrected before delivery.",
+ "i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Requires an individual decision.",
+ "i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "May be accepted together after critical review is complete.",
+ "i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "Already acknowledged in the completed review.",
+ "i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "All required review decisions are complete.",
+ "i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f": "No critical built-message blockers remain.",
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Appended",
@@ -1304,6 +1333,35 @@ export const generatedTranslations: PlatformTranslations = {
"de": {
"i18n:govoplan-campaign.guided_workflows": "Geführte Abläufe",
"i18n:govoplan-campaign.no_guided_workflows": "Es sind keine geführten Abläufe verfügbar.",
+ "i18n:govoplan-campaign.required_action.f2429497": "Erforderliche Aktion",
+ "i18n:govoplan-campaign.who_can_resolve_this.e60939b2": "Wer kann dies beheben",
+ "i18n:govoplan-campaign.where_to_go.bb1c6969": "Ziel",
+ "i18n:govoplan-campaign.critical_blockers.8a37e088": "Kritische Blockaden",
+ "i18n:govoplan-campaign.remaining.cc632b5e": "Verbleibend",
+ "i18n:govoplan-campaign.individual_review.402783bf": "Einzelprüfung",
+ "i18n:govoplan-campaign.group_review.a809a9d9": "Gruppenprüfung",
+ "i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blockierende Validierungsprobleme verhindern die Erstellung.",
+ "i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Korrigieren Sie die betroffenen Kampagnendaten und validieren Sie erneut.",
+ "i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0": "Kampagnenidentität, Absender & Empfänger, Vorlage oder Dateien",
+ "i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106": "Kampagnenbearbeitung oder -prüfung",
+ "i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1": "{value0} Validierungswarnungen müssen geprüft werden, verhindern die Erstellung aber nicht.",
+ "i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4": "Prüfen Sie die Warnungsdetails; korrigieren Sie die Daten oder akzeptieren Sie die dokumentierte Bedingung.",
+ "i18n:govoplan-campaign.review_send_validate.c1b403c7": "Prüfen & Senden - Validieren",
+ "i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930": "Der Validierungsnachweis ist veraltet.",
+ "i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9": "Validieren Sie erneut, bevor Sie sich auf dieses Ergebnis verlassen.",
+ "i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60": "{value0} erstellte Nachrichten sind blockiert oder fehlgeschlagen.",
+ "i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Korrigieren Sie die betroffenen Empfänger-, Vorlagen-, Mail- oder Anhangsdaten und erstellen Sie erneut.",
+ "i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Absender & Empfänger, Vorlage, Mail-Einstellungen oder Dateien",
+ "i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} Prüfentscheidungen verbleiben ({value1} einzeln, {value2} als Gruppe).",
+ "i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Öffnen Sie jede kritische Nachricht und dokumentieren Sie eine Entscheidung; akzeptieren Sie anschließend ausdrücklich die nichtkritische Gruppe.",
+ "i18n:govoplan-campaign.review_send_built_messages.6b030946": "Prüfen & Senden - Erstellte Nachrichten",
+ "i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} Warnungen zu erstellten Nachrichten müssen bestätigt werden.",
+ "i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Muss vor dem Versand korrigiert werden.",
+ "i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Erfordert eine Einzelentscheidung.",
+ "i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "Kann nach Abschluss der kritischen Prüfung gemeinsam akzeptiert werden.",
+ "i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "In der abgeschlossenen Prüfung bereits bestätigt.",
+ "i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "Alle erforderlichen Prüfentscheidungen sind abgeschlossen.",
+ "i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f": "Es verbleiben keine kritischen Blockaden für erstellte Nachrichten.",
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
diff --git a/webui/src/styles/campaign-workspace.css b/webui/src/styles/campaign-workspace.css
index 7ea3662..fb5f1b5 100644
--- a/webui/src/styles/campaign-workspace.css
+++ b/webui/src/styles/campaign-workspace.css
@@ -1627,6 +1627,16 @@
min-height: 150px;
}
+.review-workflow-guidance {
+ display: grid;
+ gap: 10px;
+ margin: 0 0 16px;
+}
+
+.review-workflow-progress {
+ margin-bottom: 16px;
+}
+
.attachment-linking-preview {
margin-top: 14px;
}
diff --git a/webui/tests/review-workflow-guidance-ui-structure.test.mjs b/webui/tests/review-workflow-guidance-ui-structure.test.mjs
new file mode 100644
index 0000000..263d4c9
--- /dev/null
+++ b/webui/tests/review-workflow-guidance-ui-structure.test.mjs
@@ -0,0 +1,19 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
+const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
+
+assert.match(guidance, / {
+ assert.deepEqual(calculateBuildReviewProgress({
+ blocking: 2,
+ individualRequired: 3,
+ individualReviewed: 1,
+ groupRequired: 4,
+ reviewComplete: false
+ }), {
+ blocking: 2,
+ individualRequired: 3,
+ individualReviewed: 1,
+ individualRemaining: 2,
+ groupRequired: 4,
+ groupReviewed: 0,
+ groupRemaining: 4,
+ required: 7,
+ reviewed: 1,
+ remaining: 6
+ });
+});
+
+test("a recorded review completion acknowledges every review decision", () => {
+ const progress = calculateBuildReviewProgress({
+ blocking: 0,
+ individualRequired: 3,
+ individualReviewed: 3,
+ groupRequired: 4,
+ reviewComplete: true
+ });
+ assert.equal(progress.reviewed, 7);
+ assert.equal(progress.remaining, 0);
+ assert.equal(progress.individualRemaining, 0);
+ assert.equal(progress.groupRemaining, 0);
+});
+
+test("normalizes malformed counters without overstating progress", () => {
+ const progress = calculateBuildReviewProgress({
+ blocking: -2,
+ individualRequired: 2.9,
+ individualReviewed: 99,
+ groupRequired: Number.NaN,
+ reviewComplete: false
+ });
+ assert.equal(progress.blocking, 0);
+ assert.equal(progress.individualRequired, 2);
+ assert.equal(progress.individualReviewed, 2);
+ assert.equal(progress.required, 2);
+ assert.equal(progress.remaining, 0);
+});