fix(web): show owner review single item gates
All checks were successful
CD Pipeline / tests (push) Successful in 1m40s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 4m9s
CD Pipeline / post-deploy-checks (push) Successful in 1m26s

This commit is contained in:
Your Name
2026-06-03 10:30:43 +08:00
parent 178bdbf0c3
commit 7613d93012
3 changed files with 351 additions and 209 deletions

View File

@@ -3312,6 +3312,37 @@
"batchWrites": "批次寫入允許:{value}"
}
},
"singleItemRail": {
"title": "單筆 Owner Review 處理",
"subtitle": "先乾跑取得 plan fingerprintOwner 確認後才允許寫 KM、寫 audit 並排比例回測。",
"outcome": "策略:{outcome}",
"writeGate": "確認寫 KM={writes};可確認={confirm}",
"required": "必要欄位:{fields}",
"blockers": "卡點:{blockers}",
"noBlockers": "卡點:無;可先做 dry-run preview。",
"writeGateDetail": "後端會拒絕缺 fingerprint 或未 owner_approved 的寫入;讀取與乾跑不會改 KM。",
"step": {
"dispatch": "排入審核",
"dryRun": "乾跑預覽",
"confirm": "Owner 確認",
"recheck": "比例回測"
},
"detail": {
"dispatch": "Dispatch {dispatch}",
"dryRunReady": "已取得 fingerprint可進入 owner confirm gate",
"dryRunPending": "按單筆乾跑取得 fingerprint",
"dryRunBlocked": "需先排入 owner review",
"confirmReady": "確認後會寫 KM / audit並排 recheck",
"confirmWaiting": "等待 dry-run fingerprint",
"confirmDone": "owner review 已完成",
"recheckDone": "Recheck {recheck}",
"recheckWaiting": "寫回完成後才會產生 recheck"
},
"actions": {
"preview": "單筆乾跑",
"confirm": "Owner 確認寫回"
}
},
"ownerReviewInbox": {
"title": "Owner review 工作台",
"subtitle": "顯示已排入 waiting_owner_review 的 P0/P1 KM逐筆乾跑與確認完成。",

View File

@@ -3312,6 +3312,37 @@
"batchWrites": "批次寫入允許:{value}"
}
},
"singleItemRail": {
"title": "單筆 Owner Review 處理",
"subtitle": "先乾跑取得 plan fingerprintOwner 確認後才允許寫 KM、寫 audit 並排比例回測。",
"outcome": "策略:{outcome}",
"writeGate": "確認寫 KM={writes};可確認={confirm}",
"required": "必要欄位:{fields}",
"blockers": "卡點:{blockers}",
"noBlockers": "卡點:無;可先做 dry-run preview。",
"writeGateDetail": "後端會拒絕缺 fingerprint 或未 owner_approved 的寫入;讀取與乾跑不會改 KM。",
"step": {
"dispatch": "排入審核",
"dryRun": "乾跑預覽",
"confirm": "Owner 確認",
"recheck": "比例回測"
},
"detail": {
"dispatch": "Dispatch {dispatch}",
"dryRunReady": "已取得 fingerprint可進入 owner confirm gate",
"dryRunPending": "按單筆乾跑取得 fingerprint",
"dryRunBlocked": "需先排入 owner review",
"confirmReady": "確認後會寫 KM / audit並排 recheck",
"confirmWaiting": "等待 dry-run fingerprint",
"confirmDone": "owner review 已完成",
"recheckDone": "Recheck {recheck}",
"recheckWaiting": "寫回完成後才會產生 recheck"
},
"actions": {
"preview": "單筆乾跑",
"confirm": "Owner 確認寫回"
}
},
"ownerReviewInbox": {
"title": "Owner review 工作台",
"subtitle": "顯示已排入 waiting_owner_review 的 P0/P1 KM逐筆乾跑與確認完成。",

View File

@@ -1723,6 +1723,263 @@ function staleCandidateFromOwnerReviewItem(
};
}
function ownerReviewRailTone(state: "done" | "ready" | "waiting" | "blocked") {
switch (state) {
case "done":
return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
case "ready":
return "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]";
case "blocked":
return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
default:
return "border-[#d8d3c7] bg-white text-[#5f5b52]";
}
}
function KnowledgeOwnerReviewSingleItemRail({
candidate,
action,
queuedDispatchId,
canPreview,
canConfirmAfterPreview = true,
writesKmOnConfirm = true,
requiredOwnerFields = [],
blockers = [],
onPreview,
onConfirm,
}: {
candidate: KnowledgeStaleCandidate;
action?: KnowledgeStaleOwnerReviewCompleteAction;
queuedDispatchId?: string | null;
canPreview: boolean;
canConfirmAfterPreview?: boolean;
writesKmOnConfirm?: boolean;
requiredOwnerFields?: string[];
blockers?: string[];
onPreview: () => void;
onConfirm: () => void;
}) {
const t = useTranslations("awooop.workItems.knowledgeGovernance");
const preview = action?.previewResult ?? null;
const result = action?.result ?? null;
const previewReady = Boolean(preview?.dry_run_plan_fingerprint);
const completed = result?.status === "completed" || result?.status === "already_completed";
const previewStatusKey = kmStaleReviewCompleteStatusKey(preview?.status);
const resultStatusKey = kmStaleReviewCompleteStatusKey(result?.status);
const outcome = preview?.review_outcome ?? kmStaleReviewOutcomeForCandidate(candidate);
const dryRunState = previewReady ? "done" : queuedDispatchId && canPreview ? "ready" : "waiting";
const confirmState = completed
? "done"
: !queuedDispatchId || blockers.length > 0
? "blocked"
: previewReady && canConfirmAfterPreview
? "ready"
: "waiting";
const recheckState = result?.stale_ratio_recheck_dispatch_id ? "done" : "waiting";
const steps = [
{
key: "dispatch",
icon: ClipboardList,
state: queuedDispatchId ? "done" : "blocked",
detail: t("staleCandidates.singleItemRail.detail.dispatch", {
dispatch: queuedDispatchId ?? "--",
}),
},
{
key: "dryRun",
icon: SearchCheck,
state: dryRunState,
detail: previewReady
? t("staleCandidates.singleItemRail.detail.dryRunReady")
: queuedDispatchId
? t("staleCandidates.singleItemRail.detail.dryRunPending")
: t("staleCandidates.singleItemRail.detail.dryRunBlocked"),
},
{
key: "confirm",
icon: ShieldCheck,
state: confirmState,
detail: completed
? t("staleCandidates.singleItemRail.detail.confirmDone")
: previewReady
? t("staleCandidates.singleItemRail.detail.confirmReady")
: t("staleCandidates.singleItemRail.detail.confirmWaiting"),
},
{
key: "recheck",
icon: RefreshCw,
state: recheckState,
detail: result?.stale_ratio_recheck_dispatch_id
? t("staleCandidates.singleItemRail.detail.recheckDone", {
recheck: result.stale_ratio_recheck_dispatch_id,
})
: t("staleCandidates.singleItemRail.detail.recheckWaiting"),
},
] as const;
return (
<div className="mt-2 border border-[#e0ddd4] bg-white px-3 py-2 text-[11px] leading-5 text-[#5f5b52]">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold text-[#141413]">
{t("staleCandidates.singleItemRail.title")}
</p>
<p className="mt-1 text-[#77736a]">
{t("staleCandidates.singleItemRail.subtitle")}
</p>
</div>
<div className="flex flex-wrap gap-1 font-mono">
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
{t("staleCandidates.singleItemRail.outcome", {
outcome: t(`staleCandidates.completeActions.outcomes.${outcome}` as never),
})}
</span>
<span className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5">
{t("staleCandidates.singleItemRail.writeGate", {
writes: String(writesKmOnConfirm),
confirm: String(canConfirmAfterPreview),
})}
</span>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-px border border-[#e0ddd4] bg-[#e0ddd4] sm:grid-cols-2 xl:grid-cols-4">
{steps.map((step) => {
const Icon = step.icon;
const tone = ownerReviewRailTone(step.state);
return (
<div key={step.key} className="bg-[#faf9f3] px-2 py-2">
<div className="flex items-start justify-between gap-2">
<div className={cn("flex h-7 w-7 shrink-0 items-center justify-center border", tone)}>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<span className={cn("border px-2 py-0.5 text-[10px] font-semibold", tone)}>
{t(`staleCandidates.operationRail.state.${step.state}` as never)}
</span>
</div>
<p className="mt-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#77736a]">
{t(`staleCandidates.singleItemRail.step.${step.key}` as never)}
</p>
<p className="mt-1 line-clamp-2 text-[11px] text-[#5f5b52]">
{step.detail}
</p>
</div>
);
})}
</div>
<div className="mt-2 grid gap-2 lg:grid-cols-[minmax(0,1fr)_minmax(220px,0.65fr)]">
<div className="border border-[#d8d3c7] bg-[#faf9f3] px-2 py-1.5">
<p>
{t("staleCandidates.singleItemRail.required", {
fields: requiredOwnerFields.length > 0 ? requiredOwnerFields.join(", ") : "--",
})}
</p>
{blockers.length > 0 ? (
<p className="text-[#9f2f25]">
{t("staleCandidates.singleItemRail.blockers", {
blockers: blockers.join(", "),
})}
</p>
) : (
<p>
{t("staleCandidates.singleItemRail.noBlockers")}
</p>
)}
<p className="mt-1">
{t("staleCandidates.singleItemRail.writeGateDetail")}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onPreview}
disabled={!queuedDispatchId || !canPreview || action?.previewLoading || action?.confirmLoading}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{action?.previewLoading
? t("staleCandidates.completeActions.previewing")
: t("staleCandidates.singleItemRail.actions.preview")}
</button>
<button
type="button"
onClick={onConfirm}
disabled={
!queuedDispatchId ||
!previewReady ||
!canConfirmAfterPreview ||
action?.previewLoading ||
action?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{action?.confirmLoading
? t("staleCandidates.completeActions.confirming")
: t("staleCandidates.singleItemRail.actions.confirm")}
</button>
</div>
</div>
{action?.error ? (
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[#9f2f25]">
{action.error}
</p>
) : null}
{preview ? (
<div className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1.5 text-[#8a5a08]">
<p className="font-semibold">
{t(`staleCandidates.completeActions.statuses.${previewStatusKey}` as never)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.previewResult", {
outcome: t(`staleCandidates.completeActions.outcomes.${preview.review_outcome}` as never),
writesKm: String(preview.writes_km),
writesAudit: String(preview.writes_governance_audit),
})}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
{t("staleCandidates.completeActions.planFingerprint", {
fingerprint: preview.dry_run_plan_fingerprint ?? "--",
})}
</p>
{preview.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.snapshot", {
stale: preview.stale_ratio_snapshot.stale_count,
total: preview.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(preview.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(preview.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
</div>
) : null}
{result ? (
<div className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1.5 text-[#17602a]">
<p className="font-semibold">
{t(`staleCandidates.completeActions.statuses.${resultStatusKey}` as never)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.result", {
audit: result.audit_dispatch_id ?? "--",
recheck: result.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
{result.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.snapshot", {
stale: result.stale_ratio_snapshot.stale_count,
total: result.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(result.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(result.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
</div>
) : null}
</div>
);
}
function kmCorrelationSourceKey(value: string | null | undefined) {
switch (value) {
case "incident":
@@ -4676,8 +4933,6 @@ function KnowledgeGovernancePanel({
{completionQueueItems.slice(0, 6).map((item) => {
const completionCandidate = staleCandidateFromOwnerReviewItem(item);
const completionAction = staleReviewCompletionActions[item.entry_id];
const completionPreview = completionAction?.previewResult ?? null;
const completionResult = completionAction?.result ?? null;
const readinessKey = kmStaleCompletionReadinessKey(item.readiness);
return (
<article
@@ -4735,55 +4990,18 @@ function KnowledgeGovernancePanel({
})}
</p>
) : null}
{item.can_preview ? (
<div className="mt-2 flex flex-wrap gap-2">
<button
type="button"
onClick={() => previewStaleReviewCompletion(completionCandidate)}
disabled={completionAction?.previewLoading || completionAction?.confirmLoading}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<ClipboardList className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.previewLoading
? t("staleCandidates.completeActions.previewing")
: t("staleCandidates.completeActions.preview")}
</button>
<button
type="button"
onClick={() => confirmStaleReviewCompletion(completionCandidate)}
disabled={
completionAction?.confirmLoading
|| !completionPreview?.dry_run_plan_fingerprint
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.confirmLoading
? t("staleCandidates.completeActions.confirming")
: t("staleCandidates.completeActions.confirm")}
</button>
</div>
) : null}
{completionAction?.error ? (
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] text-[#9f2f25]">
{completionAction.error}
</p>
) : null}
{completionPreview ? (
<p className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1 font-mono text-[11px] text-[#8a5a08]">
{t("staleCandidates.completeActions.planFingerprint", {
fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
})}
</p>
) : null}
{completionResult ? (
<p className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1 text-[11px] text-[#17602a]">
{t("staleCandidates.completeActions.result", {
audit: completionResult.audit_dispatch_id ?? "--",
recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
) : null}
<KnowledgeOwnerReviewSingleItemRail
candidate={completionCandidate}
action={completionAction}
queuedDispatchId={item.dispatch_id}
canPreview={item.can_preview}
canConfirmAfterPreview={item.can_confirm_after_preview}
writesKmOnConfirm={item.writes_km_on_confirm}
requiredOwnerFields={item.required_owner_fields}
blockers={item.blockers}
onPreview={() => previewStaleReviewCompletion(completionCandidate)}
onConfirm={() => confirmStaleReviewCompletion(completionCandidate)}
/>
</article>
);
})}
@@ -4830,11 +5048,6 @@ function KnowledgeGovernancePanel({
{ownerReviewItems.slice(0, 6).map((item) => {
const inboxCandidate = staleCandidateFromOwnerReviewItem(item);
const completionAction = staleReviewCompletionActions[item.entry_id];
const completionPreview = completionAction?.previewResult ?? null;
const completionResult = completionAction?.result ?? null;
const completionPreviewReady = Boolean(completionPreview?.dry_run_plan_fingerprint);
const completionPreviewStatusKey = kmStaleReviewCompleteStatusKey(completionPreview?.status);
const completionResultStatusKey = kmStaleReviewCompleteStatusKey(completionResult?.status);
return (
<article
key={item.dispatch_id}
@@ -4880,60 +5093,16 @@ function KnowledgeGovernancePanel({
})}
</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => previewStaleReviewCompletion(inboxCandidate)}
disabled={completionAction?.previewLoading || completionAction?.confirmLoading}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.previewLoading
? t("staleCandidates.completeActions.previewing")
: t("staleCandidates.completeActions.preview")}
</button>
<button
type="button"
onClick={() => confirmStaleReviewCompletion(inboxCandidate)}
disabled={
!completionPreviewReady ||
completionAction?.previewLoading ||
completionAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.confirmLoading
? t("staleCandidates.completeActions.confirming")
: t("staleCandidates.completeActions.confirm")}
</button>
</div>
{completionAction?.error ? (
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[#9f2f25]">
{completionAction.error}
</p>
) : null}
{completionPreview ? (
<p className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1 text-[#8a5a08]">
{t(
`staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never
)}{" "}
{t("staleCandidates.completeActions.planFingerprint", {
fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
})}
</p>
) : null}
{completionResult ? (
<p className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1 text-[#17602a]">
{t(
`staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never
)}{" "}
{t("staleCandidates.completeActions.result", {
audit: completionResult.audit_dispatch_id ?? "--",
recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
) : null}
<KnowledgeOwnerReviewSingleItemRail
candidate={inboxCandidate}
action={completionAction}
queuedDispatchId={item.dispatch_id}
canPreview={true}
canConfirmAfterPreview={true}
writesKmOnConfirm={true}
onPreview={() => previewStaleReviewCompletion(inboxCandidate)}
onConfirm={() => confirmStaleReviewCompletion(inboxCandidate)}
/>
</article>
);
})}
@@ -4958,11 +5127,6 @@ function KnowledgeGovernancePanel({
const persistedReviewStatusKey = governanceKmDispatchStatusKey(candidate.owner_review_status);
const persistedReviewStageKey = governanceKmStageKey(candidate.owner_review_stage);
const completionAction = staleReviewCompletionActions[candidate.entry_id];
const completionPreview = completionAction?.previewResult ?? null;
const completionResult = completionAction?.result ?? null;
const completionPreviewReady = Boolean(completionPreview?.dry_run_plan_fingerprint);
const completionPreviewStatusKey = kmStaleReviewCompleteStatusKey(completionPreview?.status);
const completionResultStatusKey = kmStaleReviewCompleteStatusKey(completionResult?.status);
return (
<article
key={candidate.entry_id}
@@ -5046,37 +5210,6 @@ function KnowledgeGovernancePanel({
? t("staleCandidates.queueingReview")
: t("staleCandidates.queueReview")}
</button>
<button
type="button"
onClick={() => previewStaleReviewCompletion(candidate)}
disabled={
!queuedDispatchId ||
completionAction?.previewLoading ||
completionAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.previewLoading
? t("staleCandidates.completeActions.previewing")
: t("staleCandidates.completeActions.preview")}
</button>
<button
type="button"
onClick={() => confirmStaleReviewCompletion(candidate)}
disabled={
!queuedDispatchId ||
!completionPreviewReady ||
completionAction?.previewLoading ||
completionAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.confirmLoading
? t("staleCandidates.completeActions.confirming")
: t("staleCandidates.completeActions.confirm")}
</button>
<Link
href={`/knowledge-base?q=${encodeURIComponent(candidate.title)}`}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
@@ -5099,69 +5232,16 @@ function KnowledgeGovernancePanel({
})}
</p>
) : null}
{completionAction?.error ? (
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] leading-5 text-[#9f2f25]">
{completionAction.error}
</p>
) : null}
{completionPreview ? (
<div className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1.5 text-[11px] leading-5 text-[#8a5a08]">
<p className="font-semibold">
{t(
`staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never
)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.previewResult", {
outcome: t(
`staleCandidates.completeActions.outcomes.${completionPreview.review_outcome}` as never
),
writesKm: String(completionPreview.writes_km),
writesAudit: String(completionPreview.writes_governance_audit),
})}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
{t("staleCandidates.completeActions.planFingerprint", {
fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
})}
</p>
{completionPreview.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.snapshot", {
stale: completionPreview.stale_ratio_snapshot.stale_count,
total: completionPreview.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(completionPreview.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(completionPreview.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
</div>
) : null}
{completionResult ? (
<div className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1.5 text-[11px] leading-5 text-[#17602a]">
<p className="font-semibold">
{t(
`staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never
)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.result", {
audit: completionResult.audit_dispatch_id ?? "--",
recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
{completionResult.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.snapshot", {
stale: completionResult.stale_ratio_snapshot.stale_count,
total: completionResult.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(completionResult.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(completionResult.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
</div>
) : null}
<KnowledgeOwnerReviewSingleItemRail
candidate={candidate}
action={completionAction}
queuedDispatchId={queuedDispatchId}
canPreview={Boolean(queuedDispatchId)}
canConfirmAfterPreview={true}
writesKmOnConfirm={true}
onPreview={() => previewStaleReviewCompletion(candidate)}
onConfirm={() => confirmStaleReviewCompletion(candidate)}
/>
</article>
);
})}