diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 288bf8a2..1be4890f 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -3312,6 +3312,37 @@
"batchWrites": "批次寫入允許:{value}"
}
},
+ "singleItemRail": {
+ "title": "單筆 Owner Review 處理",
+ "subtitle": "先乾跑取得 plan fingerprint;Owner 確認後才允許寫 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,逐筆乾跑與確認完成。",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 288bf8a2..1be4890f 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -3312,6 +3312,37 @@
"batchWrites": "批次寫入允許:{value}"
}
},
+ "singleItemRail": {
+ "title": "單筆 Owner Review 處理",
+ "subtitle": "先乾跑取得 plan fingerprint;Owner 確認後才允許寫 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,逐筆乾跑與確認完成。",
diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
index 92eaab86..b4d520bd 100644
--- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -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 (
+
+
+
+
+ {t("staleCandidates.singleItemRail.title")}
+
+
+ {t("staleCandidates.singleItemRail.subtitle")}
+
+
+
+
+ {t("staleCandidates.singleItemRail.outcome", {
+ outcome: t(`staleCandidates.completeActions.outcomes.${outcome}` as never),
+ })}
+
+
+ {t("staleCandidates.singleItemRail.writeGate", {
+ writes: String(writesKmOnConfirm),
+ confirm: String(canConfirmAfterPreview),
+ })}
+
+
+
+
+ {steps.map((step) => {
+ const Icon = step.icon;
+ const tone = ownerReviewRailTone(step.state);
+ return (
+
+
+
+
+
+
+ {t(`staleCandidates.operationRail.state.${step.state}` as never)}
+
+
+
+ {t(`staleCandidates.singleItemRail.step.${step.key}` as never)}
+
+
+ {step.detail}
+
+
+ );
+ })}
+
+
+
+
+ {t("staleCandidates.singleItemRail.required", {
+ fields: requiredOwnerFields.length > 0 ? requiredOwnerFields.join(", ") : "--",
+ })}
+
+ {blockers.length > 0 ? (
+
+ {t("staleCandidates.singleItemRail.blockers", {
+ blockers: blockers.join(", "),
+ })}
+
+ ) : (
+
+ {t("staleCandidates.singleItemRail.noBlockers")}
+
+ )}
+
+ {t("staleCandidates.singleItemRail.writeGateDetail")}
+
+
+
+
+
+
+
+ {action?.error ? (
+
+ {action.error}
+
+ ) : null}
+ {preview ? (
+
+
+ {t(`staleCandidates.completeActions.statuses.${previewStatusKey}` as never)}
+
+
+ {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),
+ })}
+
+
+ {t("staleCandidates.completeActions.planFingerprint", {
+ fingerprint: preview.dry_run_plan_fingerprint ?? "--",
+ })}
+
+ {preview.stale_ratio_snapshot ? (
+
+ {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),
+ })}
+
+ ) : null}
+
+ ) : null}
+ {result ? (
+
+
+ {t(`staleCandidates.completeActions.statuses.${resultStatusKey}` as never)}
+
+
+ {t("staleCandidates.completeActions.result", {
+ audit: result.audit_dispatch_id ?? "--",
+ recheck: result.stale_ratio_recheck_dispatch_id ?? "--",
+ })}
+
+ {result.stale_ratio_snapshot ? (
+
+ {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),
+ })}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
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 (
) : null}
- {item.can_preview ? (
-
-
-
-
- ) : null}
- {completionAction?.error ? (
-
- {completionAction.error}
-
- ) : null}
- {completionPreview ? (
-
- {t("staleCandidates.completeActions.planFingerprint", {
- fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
- })}
-
- ) : null}
- {completionResult ? (
-
- {t("staleCandidates.completeActions.result", {
- audit: completionResult.audit_dispatch_id ?? "--",
- recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
- })}
-
- ) : null}
+ previewStaleReviewCompletion(completionCandidate)}
+ onConfirm={() => confirmStaleReviewCompletion(completionCandidate)}
+ />
);
})}
@@ -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 (
-
-
-
-
- {completionAction?.error ? (
-
- {completionAction.error}
-
- ) : null}
- {completionPreview ? (
-
- {t(
- `staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never
- )}{" "}
- {t("staleCandidates.completeActions.planFingerprint", {
- fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
- })}
-
- ) : null}
- {completionResult ? (
-
- {t(
- `staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never
- )}{" "}
- {t("staleCandidates.completeActions.result", {
- audit: completionResult.audit_dispatch_id ?? "--",
- recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
- })}
-
- ) : null}
+ previewStaleReviewCompletion(inboxCandidate)}
+ onConfirm={() => confirmStaleReviewCompletion(inboxCandidate)}
+ />
);
})}
@@ -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 (
-
-
) : null}
- {completionAction?.error ? (
-
- {completionAction.error}
-
- ) : null}
- {completionPreview ? (
-
-
- {t(
- `staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never
- )}
-
-
- {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),
- })}
-
-
- {t("staleCandidates.completeActions.planFingerprint", {
- fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
- })}
-
- {completionPreview.stale_ratio_snapshot ? (
-
- {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),
- })}
-
- ) : null}
-
- ) : null}
- {completionResult ? (
-
-
- {t(
- `staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never
- )}
-
-
- {t("staleCandidates.completeActions.result", {
- audit: completionResult.audit_dispatch_id ?? "--",
- recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
- })}
-
- {completionResult.stale_ratio_snapshot ? (
-
- {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),
- })}
-
- ) : null}
-
- ) : null}
+ previewStaleReviewCompletion(candidate)}
+ onConfirm={() => confirmStaleReviewCompletion(candidate)}
+ />
);
})}