Clarify campaign review interventions

This commit is contained in:
2026-08-03 07:22:27 +02:00
parent 5df26be074
commit 1d6c745991
11 changed files with 416 additions and 18 deletions
+8
View File
@@ -141,6 +141,14 @@ just the authoring form:
content, recipients, attachment inputs, owner context, or non-secret content, recipients, attachment inputs, owner context, or non-secret
transport identity changes, revalidate and rebuild. 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 This evidence is the Campaign input to separation-of-duties policy. Generic
approve/reject chains, delegation, substitutions, escalation, and signatures approve/reject chains, delegation, substitutions, escalation, and signatures
belong to the optional Approvals capability. Campaign must not claim an belong to the optional Approvals capability. Campaign must not claim an
+9 -8
View File
@@ -881,8 +881,8 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="campaigns.workflow.complete-review", id="campaigns.workflow.complete-review",
title="Inspect built messages and 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.", summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
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.", 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", layer="configured",
documentation_types=("user",), documentation_types=("user",),
audience=("campaign_reviewer",), audience=("campaign_reviewer",),
@@ -918,13 +918,14 @@ manifest = ModuleManifest(
"You may read the campaign and complete its review.", "You may read the campaign and complete its review.",
], ],
"steps": [ "steps": [
"Confirm the campaign, owner, selected version, recipient count, warnings, and exclusions.", "Open Review and send and inspect the Critical blockers, Individual review, and Group review summaries.",
"Inspect representative and exceptional messages, addressing, templates, and attachment evidence.", "Correct every critical blocker in the named campaign workspace, then validate and build again.",
"Confirm that the selected Mail profile is suitable and authorized for the current context.", "Open each remaining individual review message and record its decision.",
"Record review completion for the exact message keys inspected.", "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.", "outcome": "Review evidence for the exact current build, with no unresolved blocker or review decision.",
"verification": "Reload Review and confirm completion is tied to the same version and message build; changed inputs must invalidate or supersede it.", "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": [ "related_topic_ids": [
"campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.retry-and-reconcile", "campaigns.workflow.retry-and-reconcile",
+24
View File
@@ -54,6 +54,30 @@ def test_campaign_runtime_documentation_provider_is_registered() -> None:
assert documentation_topics in get_manifest().documentation_providers 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: def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
assert _topics({"docs:documentation:read"}) == () assert _topics({"docs:documentation:read"}) == ()
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == () assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
+1
View File
@@ -30,6 +30,7 @@
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs", "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: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-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: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: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", "test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
+23 -10
View File
@@ -72,6 +72,12 @@ import DeliverabilityPreflight, {
} from "./review/DeliverabilityPreflight"; } from "./review/DeliverabilityPreflight";
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay"; import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
import BuiltMessagePreview from "./review/BuiltMessagePreview"; import BuiltMessagePreview from "./review/BuiltMessagePreview";
import {
BuiltMessageReviewProgress,
BuiltMessageWorkflowGuidance,
ValidationWorkflowGuidance
} from "./review/ReviewWorkflowGuidance";
import { calculateBuildReviewProgress } from "./review/reviewProgress";
import { import {
WorkflowFact, WorkflowFact,
WorkflowNavigation, WorkflowNavigation,
@@ -466,7 +472,6 @@ export default function ReviewSendPage({
Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size
); );
const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount; const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount;
const reviewedRequiredCount = reviewedExplicitCount;
const automaticInspectionComplete = reviewJobs.total_unfiltered > 0 && const automaticInspectionComplete = reviewJobs.total_unfiltered > 0 &&
blockingReviewCount === 0 && blockingReviewCount === 0 &&
reviewRequiredCount === 0; reviewRequiredCount === 0;
@@ -505,6 +510,13 @@ export default function ReviewSendPage({
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted; const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity; 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 ? const buildReviewState: FlowState = !readyForDelivery ?
"locked" : "locked" :
@@ -512,7 +524,7 @@ export default function ReviewSendPage({
"running" : "running" :
hasBuild && buildBlocked > 0 ? hasBuild && buildBlocked > 0 ?
"danger" : "danger" :
hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied) ? hasBuild && !inspectionSatisfied && (buildNeedsReview > 0 || buildWarnings > 0 || buildReviewProgress.remaining > 0) ?
"warning" : "warning" :
hasBuild && inspectionSatisfied ? hasBuild && inspectionSatisfied ?
"complete" : "complete" :
@@ -1392,13 +1404,14 @@ export default function ReviewSendPage({
<WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} /> <WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
</div> </div>
{validationStale && <p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.the_stored_validation_result_is_no_longer_an_act.45d2dd14</p>} <ValidationWorkflowGuidance
errors={validationErrors}
warnings={validationWarnings}
stale={validationStale}
/>
{validationPresent && validationErrors === 0 && {validationPresent && validationErrors === 0 &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p> <p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
} }
{validationErrors > 0 &&
<p className="review-flow-inline-note is-danger">i18n:govoplan-campaign.resolve_the_blocking_entries_then_validate_again.e282ae0f</p>
}
<AttachmentLinkingPreview <AttachmentLinkingPreview
preview={attachmentPreview} preview={attachmentPreview}
loading={attachmentPreviewLoading} loading={attachmentPreviewLoading}
@@ -1447,10 +1460,13 @@ export default function ReviewSendPage({
<WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? reviewedRequiredCount : "—"} /> <WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
<WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} /> <WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} /> <WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
</div> </div>
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p> <p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
{getText(printOutput, "render_id") && ( {getText(printOutput, "render_id") && (
<div className="review-flow-data-section"> <div className="review-flow-data-section">
@@ -1501,9 +1517,6 @@ export default function ReviewSendPage({
{automaticInspectionComplete && {automaticInspectionComplete &&
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p> <p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
} }
{blockingReviewCount > 0 &&
<p className="review-flow-inline-note is-danger">{blockingReviewCount} i18n:govoplan-campaign.blocked_or_failed_message_s_must_be_resolved_bef.5c6b5140</p>
}
{hasBuild && {hasBuild &&
<div className="review-flow-data-section"> <div className="review-flow-data-section">
<div className="page-heading split"> <div className="page-heading split">
@@ -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 (
<ActionBlockerHint
tone={tone}
reason={reason}
labels={{
requiredAction: requiredActionLabel,
actor: actorLabel,
target: destinationLabel
}}
/>
);
}
export function ValidationWorkflowGuidance({
errors,
warnings,
stale
}: {
errors: number;
warnings: number;
stale: boolean;
}) {
return (
<div className="review-workflow-guidance">
{errors > 0 && (
<InterventionHint
tone="danger"
summary={i18nMessage("i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a", { value0: errors })}
requiredAction={i18nMessage("i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99")}
destination={i18nMessage("i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0")}
/>
)}
{stale && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930")}
requiredAction={i18nMessage("i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_validate.c1b403c7")}
/>
)}
{warnings > 0 && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1", { value0: warnings })}
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_validate.c1b403c7")}
/>
)}
</div>
);
}
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 (
<GuidedReviewList
className="review-workflow-progress"
items={[
{
label: i18nMessage("i18n:govoplan-campaign.critical_blockers.8a37e088"),
value: progress.blocking,
detail: blockerDetail,
tone: progress.blocking > 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 (
<div className="review-workflow-guidance">
{progress.blocking > 0 && (
<InterventionHint
tone="danger"
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60", { value0: progress.blocking })}
requiredAction={i18nMessage("i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373")}
destination={i18nMessage("i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62")}
/>
)}
{progress.remaining > 0 && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c", {
value0: progress.remaining,
value1: progress.individualRemaining,
value2: progress.groupRemaining
})}
requiredAction={i18nMessage("i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
/>
)}
{showUnacknowledgedWarning && (
<InterventionHint
tone="warning"
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2", { value0: buildWarnings })}
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
/>
)}
</div>
);
}
@@ -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
};
}
+58
View File
@@ -4,6 +4,35 @@ export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-campaign.guided_workflows": "Guided workflows", "i18n:govoplan-campaign.guided_workflows": "Guided workflows",
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.", "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.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns", "i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Appended", "i18n:govoplan-campaign.appended.979f5a82": "Appended",
@@ -1304,6 +1333,35 @@ export const generatedTranslations: PlatformTranslations = {
"de": { "de": {
"i18n:govoplan-campaign.guided_workflows": "Geführte Abläufe", "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.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.1_campaign.ccd70074": "1 campaign",
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns", "i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt", "i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
+10
View File
@@ -1627,6 +1627,16 @@
min-height: 150px; min-height: 150px;
} }
.review-workflow-guidance {
display: grid;
gap: 10px;
margin: 0 0 16px;
}
.review-workflow-progress {
margin-bottom: 16px;
}
.attachment-linking-preview { .attachment-linking-preview {
margin-top: 14px; margin-top: 14px;
} }
@@ -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, /<ActionBlockerHint/);
assert.match(guidance, /<GuidedReviewList/);
assert.match(guidance, /tone="danger"/);
assert.match(guidance, /tone="warning"/);
assert.match(guidance, /requiredAction: requiredActionLabel/);
assert.match(guidance, /target: destinationLabel/);
assert.match(page, /calculateBuildReviewProgress/);
assert.match(page, /<WorkflowFact label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { calculateBuildReviewProgress } from "../src/features/campaigns/review/reviewProgress.ts";
test("separates blocking, individual, and group review work", () => {
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);
});