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