From 1d6c74599179c0b7009431a19a2b0b317031f153 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 07:22:27 +0200 Subject: [PATCH] Clarify campaign review interventions --- docs/CAMPAIGN_HANDBOOK.md | 8 + src/govoplan_campaign/backend/manifest.py | 17 +- tests/test_documentation.py | 24 +++ webui/package.json | 1 + .../src/features/campaigns/ReviewSendPage.tsx | 33 ++-- .../review/ReviewWorkflowGuidance.tsx | 161 ++++++++++++++++++ .../campaigns/review/reviewProgress.ts | 49 ++++++ webui/src/i18n/generatedTranslations.ts | 58 +++++++ webui/src/styles/campaign-workspace.css | 10 ++ ...ew-workflow-guidance-ui-structure.test.mjs | 19 +++ webui/tests/review-workflow-guidance.test.ts | 54 ++++++ 11 files changed, 416 insertions(+), 18 deletions(-) create mode 100644 webui/src/features/campaigns/review/ReviewWorkflowGuidance.tsx create mode 100644 webui/src/features/campaigns/review/reviewProgress.ts create mode 100644 webui/tests/review-workflow-guidance-ui-structure.test.mjs create mode 100644 webui/tests/review-workflow-guidance.test.ts 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 &&

} - {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 &&

} - {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); +});