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
@@ -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
};
}