[CLAUDE] FE-User: YC-030 Q1/Q2/Q3 — my-contracts layout KHKK-thuần (ContractListPanel) + panel duyệt 3 món + máy duyệt về panel-3 (5 host ×2 app)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 21:58:08 +07:00
parent 698603625b
commit 59cb121738
12 changed files with 895 additions and 338 deletions

View File

@ -12,9 +12,8 @@ import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
// [W6 S187] Màn duyệt V2-trạm — CHỈ dùng cho HĐ đã pin `approvalWorkflowId` (xem khối
// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy.
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
// [WQ3b S190 · YC-030 Q5] 🧊 Import `ContractWorkflowPanel` (W6/S187) ĐÃ GỠ khỏi file này —
// máy duyệt dời sang PANEL-3 của từng host, xem khối lineage ở `:isV2`.
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
@ -133,13 +132,23 @@ export function ContractDetailContent({
// duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`)
// VÀ cùng cờ mục "Ý kiến cấp duyệt (Quy trình V2)" bên dưới đã dùng từ S29. Một cờ, ba nơi
// đọc, không đẻ khái niệm "HĐ mới" thứ hai.
// • V2 ⇒ `ContractWorkflowPanel` (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại,
// gating theo Cấp đang chờ).
// • V2 ⇒ máy duyệt V2-trạm (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại,
// gating theo Cấp đang chờ) — 🔴 [WQ3b S190 · YC-030 Q5] KHÔNG CÒN render trong file này.
// • V1 legacy (không pin — 7 HĐ prod chạy nhánh này, `ContractWorkflowService.cs:110`)
// ⇒ GIỮ NGUYÊN 2 nút "Yêu cầu sửa"/"Duyệt → tiếp" ở header + Dialog chọn phase.
// 2 khối là THAY THẾ nhau, không chồng: để cả hai thì cùng một HĐ V2 có 2 đường bấm duyệt
// (đường header không nói được đang ở Bước/Cấp nào, cũng không gating theo người) — mâu
// thuẫn hiển thị chứ không phải tiện thêm.
// thuẫn hiển thị chứ không phải tiện thêm. `isV2` Ở LẠI vì nửa V1 (`!isV2` ở header) vẫn đọc.
//
// ===== [WQ3b S190 · YC-030 Q5] MÁY DUYỆT DỜI VỀ PANEL-3 (khuôn KHKK) ====================
// 🧊 W6/S187 đặt `ContractWorkflowPanel` ngay TRONG file này (nhánh `isV2`, chỗ khối JSX đã
// gỡ bên dưới) = panel 2 của các host 3-panel. FLAG WQ1 (`MyContractsPage.tsx:18-23`) chỉ ra
// hệ quả: panel 3 muốn mang máy duyệt như `KhkkListPage` thì MỘT HĐ có HAI bộ nút Duyệt/Trả
// lại — đúng thứ chính khối trên cấm. Owner chốt YC-030 Q5 "cấu trúc duyệt như KHKK" ⇒ máy
// duyệt ra khỏi thân chi tiết, mỗi HOST tự render ở panel cạnh (`MyContractsPage` ·
// `ContractDetailPage` ×2 · `ContractsListPage` fe-admin · `InboxPage`), CÙNG điều kiện
// `approvalWorkflowId != null` đang tính ở dưới. Host nào quên render thì HĐ V2 ở host đó
// KHÔNG có đường duyệt — đây là chỗ dễ hụt nhất của lượt dời này, đã liệt đủ 5 host.
const isV2 = c.approvalWorkflowId != null
// ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) =====================================
@ -187,8 +196,9 @@ export function ContractDetailContent({
<PhaseBadge phase={c.phase} />
</div>
</div>
{/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở
`ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */}
{/* [W6 S187 · sửa WQ3b S190] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2
thao tác duyệt ở máy duyệt V2-trạm tại PANEL-3 của host (đúng Bước/Cấp + đúng
người), không ở header này. */}
{!isV2 && availableTargets.length > 0 && (
<div className="flex shrink-0 gap-2">
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
@ -321,18 +331,10 @@ export function ContractDetailContent({
</section>
)}
{/* [W6 S187 · YC-023 GĐ3] Màn duyệt V2-trạm — nửa V2 của dual-render.
🔴 ĐỨNG CAO, ngay dưới khối nhận-dạng HĐ: 2 nút duyệt của nhánh V1 vốn nằm ở HEADER
dính (`sticky top-0`), nên nếu nhánh V2 đẩy thao tác duyệt xuống tn cuối trang thì
đúng người-đang-chờ-duyệt lại phải cuộn đi tìm — mất chỗ đứng cũ chứ không phải sắp
xếp lại. Cùng lý do khuôn nguồn KHKK để panel ở cột-3 luôn nhìn thấy.
Vỏ `<section className="rounded-lg border … p-5">` bám đúng khuôn các khối cùng trang
(2 app đều KHÔNG có shadcn `Card`). */}
{isV2 && (
<section className="rounded-lg border border-brand-200 bg-white p-5">
<ContractWorkflowPanel contract={c} />
</section>
)}
{/* [WQ3b S190 · YC-030 Q5] Ở ĐÂY TRƯỚC LÀ máy duyệt V2-trạm (W6/S187) — đã dời sang
panel-3 của từng host theo khuôn KHKK; lý do + danh sách host xem khối `isV2` ở trên.
Chỗ trống này CỐ Ý không lấp bằng khối khác: thứ tự đọc còn lại (thông tin → nguồn
KHKK → góp ý → file → chi tiết) là thứ tự cũ, không xáo thêm trong cùng một lượt. */}
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">

View File

@ -23,20 +23,26 @@
// phân 2 ngả bằng ĐÚNG `targetPhase`: `== TuChoi` ⇒ TuChoi, else ⇒ TraLai. Gửi 98 tường
// minh thay vì "số nào cũng được miễn khác 99".
//
// ══ 3 MẢNH CỦA KHUÔN KHKK CỐ Ý KHÔNG PORT (đo ra, không phải cắt cho gọn) ═════════════
// 1. ❌ Ô-tích "Cấp này KẾT THÚC quy trình": `TransitionContractBody` có ĐÚNG 3 field;
// `skipToFinal` là tham số C# default (`TransitionAsync(..., bool skipToFinal=false)`)
// KHÔNG nằm trên API surface ⇒ vẽ ô-tích = control chết, tích xong không đổi gì.
// 2. ❌ Badge "⚑ Duyệt thay CEO" cho Cấp có `allowApproverFinalize`: `ApproveV2Async` của HĐ
// KHÔNG ĐỌC cờ đó lần nào (`ContractWorkflowService.cs:230-407` — cờ duy nhất nó đọc là
// `AllowApproverSkipToFinal` `:335`, cho đường skip mà FE không gọi được). DTO vẫn trả
// field (`ContractDtos.cs:138`) và chú thích W2 ghi rõ "dùng hay không là quyết của W6".
// Vẽ badge nói "duyệt ở đây là KẾT THÚC" = NÓI DỐI: duyệt xong vẫn chạy tiếp Cấp sau.
// 3. ❌ Banner kết-thúc-sớm + meta `{code} v{NN} · {name}`: `ContractDetail` 0 field
// `endedByLevelFinalize`/`workflowCode`/`workflowVersion`/`workflowName`. Mượn
// `c.workflow.policyName` là ghép nhãn của workflow-policy V1 vào quy trình V2 — hai
// vật khác nhau. Cây thô đã INLINE trong detail ⇒ cũng KHÔNG gọi thêm
// `/approval-workflows-v2/{id}` ("KHÔNG đẻ đường thứ 3" — `ContractDtos.cs:86-88`).
// ══ [WQ3b S190 · YC-030 Q3] 3 MẢNH KHUÔN KHKK — TỪ CHỐI @S187, PORT ĐỦ ĐỢT NÀY ════════
// 🧊 Bản S187 liệt 3 mảnh này là "CỐ Ý KHÔNG PORT" và lý do khi đó ĐÚNG: cả ba đều thiếu
// máy ở BE. WQ3-BE (cùng phiên S190) dựng xong máy ⇒ 3 lý do đó HẾT HIỆU LỰC, không phải
// bị bỏ qua. Ghi lại lineage để người sau đọc panel này không tưởng ai đó lờ mất chú thích:
// 1. ✅ Ô-tích "Cấp này KẾT THÚC quy trình" — `TransitionContractBody` nay có
// `ApplyLevelFinalize`/`SkipToFinal` (`ContractsController.cs:269-274`), `?? true` ở
// `:71`. Trước đây vẽ ô-tích là control CHẾT; nay tích/bỏ-tích đổi thật đường đi.
// 🔴 Ô-tích là OPT-OUT: BE cần CẢ HAI vế `AllowApproverFinalize ∧ applyLevelFinalize`
// (`ContractWorkflowService.cs:527`), mà bỏ trống ⇒ `true`. Vì vậy panel gửi giá trị
// TƯỜNG MINH mỗi lần duyệt, không dựa vào mặc định của server.
// 2. ✅ Badge cho Cấp có `allowApproverFinalize` — `ApproveV2Async` NAY ĐỌC cờ đó (`:527`)
// ⇒ câu "duyệt ở đây là KẾT THÚC" không còn nói dối. CHỮ trên badge KHÔNG chép nguyên
// KHKK ("⚑ Duyệt thay CEO"): quy trình HĐ không có trạm CEO nào để "thay" — terminal
// của HĐ là `DaPhatHanh` + gen mã RG-001 (`:392`). Dùng chữ đúng vật: "⚑ Cấp kết thúc".
// 3. ✅ Banner kết-thúc-sớm + meta `{code} v{NN}` — `ContractDetail` nay có
// `workflowCode`/`workflowVersion`/`endedByLevelFinalize` (`ContractDtos.cs:103-105`),
// không phải mượn `c.workflow.policyName` của V1 nữa. ⚠️ VẪN THIẾU so với KHKK:
// `workflowName` + `finalizeStepName`/`finalizeLevelName` — BE HĐ không trả 3 field đó
// ⇒ meta chỉ `{code} v{NN}`, còn tên Bước/Cấp kết-thúc panel TỰ SUY từ ý-kiến ký gần
// nhất (xem chỗ dựng `finalizeMark`), KHÔNG bịa field và KHÔNG gọi thêm endpoint thứ 3.
//
// ══ 2 KHỐI BỎ VÌ TRÙNG CHỖ ĐÃ CÓ TRÊN CÙNG TRANG ════════════════════════════════════
// Cả 4 host (`ContractDetailPage` ×2 app · `MyContractsPage` · `ContractsListPage` ·
@ -62,6 +68,7 @@ import {
type ContractDetail,
type ContractLevelOpinion,
type ContractWorkflowLevel,
type TransitionContractBody,
} from '@/types/contracts'
type NodeStatus = 'Done' | 'Current' | 'Pending'
@ -102,6 +109,10 @@ export function ContractWorkflowPanel({
const { user } = useAuth()
const [action, setAction] = useState<PanelAction | null>(null)
const [comment, setComment] = useState('')
// [WQ3b S190] Mặc định KHÔNG tick — khuôn KHKK (`KhkkWorkflowPanel.tsx:103`, vốn theo
// `PeWorkflowPanel.tsx:61`). Người duyệt phải chủ động chọn "dừng ở đây"; mặc-định-tick là
// đổi hành vi im lặng của mọi Cấp được uỷ quyền.
const [applyLevelFinalize, setApplyLevelFinalize] = useState(false)
const steps = c.workflowSteps ?? []
const curStepIdx = c.currentWorkflowStepIndex
@ -154,12 +165,43 @@ export function ContractWorkflowPanel({
const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel
// [WQ3b S190] Ô-tích phải soi cờ của ĐÚNG Cấp mà BE sẽ chấm, không phải "có Cấp nào trong
// nhóm bật cờ". Mirror `matchingLevel` (`ContractWorkflowService.cs:442-443`): own-level
// TRƯỚC, Admin không có own ⇒ phần tử ĐẦU nhóm cùng-order. Soi cờ của Cấp khác là hiện một
// ô-tích nói dối — tích xong BE vẫn trình tiếp vì cờ ở Cấp bị chấm là `false`.
const actingLevel = currentLevels.find(l => l.approverUserId === user?.id)
?? (isAdmin ? currentLevels[0] : undefined)
const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true
// [WQ3b S190] Tên Bước/Cấp đã KẾT THÚC sớm — DTO HĐ không có `finalizeStepName`/
// `finalizeLevelName` như KHKK (`ContractDtos.cs:103-105` chỉ 3 field). Suy từ ý-kiến ký
// GẦN NHẤT: `ApproveV2Async` UPSERT opinion NGAY TRƯỚC nhánh finalize trong cùng giao dịch
// (`:435-529`) ⇒ chữ ký cuối cùng chính là Cấp đã bấm kết thúc. Không suy được (0 ý kiến,
// phiếu cũ) ⇒ để `null` và banner nói câu chung, KHÔNG bịa tên.
const finalizeMark = useMemo(() => {
const ops = c.levelOpinions ?? []
if (ops.length === 0) return null
const last = [...ops].sort((a, b) => Date.parse(b.signedAt) - Date.parse(a.signedAt))[0]
return { stepOrder: last.stepOrder, stepName: last.stepName, levelOrder: last.levelOrder, levelName: last.levelName }
}, [c.levelOpinions])
const transition = useMutation({
mutationFn: async (a: PanelAction) => {
// Xem khối WIRE ở đầu file: cặp (targetPhase, decision) LÀ thứ chọn nhánh trong
// `TransitionAsync`, không phải một tên endpoint riêng.
const body = a === 'approve'
? { targetPhase: ContractPhase.ChoDuyet, decision: ApprovalDecision.Approve, comment: comment.trim() || null }
// [WQ3b S190 · YC-030 Q3] `applyLevelFinalize` CHỈ gắn ở nhánh duyệt, và GẮN TƯỜNG MINH
// kể cả khi Cấp không được uỷ quyền: bỏ trống ⇒ controller `?? true`
// (`ContractsController.cs:71`), tức im lặng = ĐỒNG Ý kết thúc sớm. Gửi
// `approverFinalizeEligible ? applyLevelFinalize : true` = đúng khuôn KHKK
// (`KhkkWorkflowPanel.tsx:199`): Cấp không bật cờ thì vế BE `AllowApproverFinalize`
// đã `false` nên giá trị này vô hại; Cấp có bật thì đi theo đúng ô-tích người dùng chọn.
const body: TransitionContractBody = a === 'approve'
? {
targetPhase: ContractPhase.ChoDuyet,
decision: ApprovalDecision.Approve,
comment: comment.trim() || null,
applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true,
}
: { targetPhase: ContractPhase.TraLai, decision: ApprovalDecision.Reject, comment: comment.trim() || null }
await api.post(`/contracts/${c.id}/transitions`, body)
},
@ -167,6 +209,10 @@ export function ContractWorkflowPanel({
toast.success(a === 'approve' ? 'Đã duyệt cấp hiện tại.' : 'Đã trả lại người soạn sửa.')
setAction(null)
setComment('')
// Trả ô-tích về mặc định. (Khuôn KHKK `:215-220` KHÔNG reset cờ này — với HĐ thì tick còn
// sót là nguy: duyệt Cấp 1 có tick nhưng cấp đó không được uỷ quyền ⇒ BE bỏ qua, sang Cấp 2
// ĐƯỢC uỷ quyền thì hộp thoại mở ra đã sẵn tick, kết thúc sớm mà người bấm không chủ ý.)
setApplyLevelFinalize(false)
// `['contract', id]` = queryKey detail của CẢ 4 host (`ContractDetailPage` ×2 app,
// `MyContractsPage`, `ContractsListPage`, `InboxPage`). 3 key còn lại là prefix của
// các danh sách — TanStack khớp theo tiền tố nên không cần liệt đủ tham số lọc.
@ -192,15 +238,42 @@ export function ContractWorkflowPanel({
return (
<div className="space-y-4">
{/* ── 1. Header ───────────────────────────────────────────────────────── */}
{/* ── 1. Header + meta {code} v{NN} ───────────────────────────────────── */}
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
{/* [WQ3b S190] Mã + phiên bản quy trình ĐANG PIN — khuôn `KhkkWorkflowPanel.tsx:286-289`.
🔴 `font-mono` vì đây là MÃ (`HD-TP`…), và `padStart(2,'0')` cho `v01` ≠ `v1` để
người đọc so nhanh với bản Designer. KHÔNG có `workflowName` như KHKK — BE HĐ không
trả field đó (xem khối lineage đầu file), nên dòng này dừng ở mã + phiên bản. */}
{c.workflowCode && (
<p className="mt-0.5 font-mono text-[11px] text-slate-500">
{c.workflowCode}
{c.workflowVersion != null && ` v${String(c.workflowVersion).padStart(2, '0')}`}
</p>
)}
<p className="mt-0.5 text-[11px] text-slate-500">
Bước = Phòng · Cấp = nhân viên duyệt. Nhiều nhân viên cùng một Cấp thì chỉ cần MỘT
người duyệt sang Cấp kế.
</p>
</div>
{/* ── 1-bis. Banner kết-thúc-sớm ──────────────────────────────────────── */}
{/* [WQ3b S190 · YC-030 Q3] Cờ RUNTIME `endedByLevelFinalize` (BE `:529`) — chỉ bật khi
việc ĐÃ xảy ra, nên câu ở thì QUÁ KHỨ. Nếu không có banner này, HĐ kết thúc sớm trông
y hệt HĐ… bị bỏ dở: cây còn 2-3 Bước ○ chưa ai ký mà phiếu lại `Đã phát hành`.
Tông EMERALD khuôn KHKK (`:299`) = "kết thúc hợp lệ", không dùng violet/amber. */}
{c.endedByLevelFinalize && (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
Quy trình đã KẾT THÚC sớm tại cấp đưc uỷ quyền không trình tiếp
{finalizeMark && (
<> (Bước {finalizeMark.stepOrder}
{finalizeMark.stepName ? `${finalizeMark.stepName}` : ''} · Cấp {finalizeMark.levelOrder}
{finalizeMark.levelName ? `${finalizeMark.levelName}` : ''})</>
)}
. Các Bước/Cấp phía sau vẽ mờ không ai phải duyệt.
</div>
)}
{/* ── 2. Sơ đồ Bước → Cấp → NV ────────────────────────────────────────── */}
{steps.length > 0 ? (
<ol className="space-y-2">
@ -225,9 +298,17 @@ export function ContractWorkflowPanel({
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{orders.map(order => {
const group = step.levels.filter(l => l.order === order)
const ls: NodeStatus = levelStatus(idx, order)
// [WQ3b S190] Cấp được uỷ quyền kết thúc — cờ CẤU HÌNH (khác cờ runtime
// `endedByLevelFinalize` ở banner trên).
const finalizeHere = group.some(l => l.allowApproverFinalize)
const hasSigned = group.some(l => signedByLevelId.has(l.id))
// Nhánh KHÔNG CHẠY: HĐ đã phát hành bằng đường kết-thúc-sớm ⇒ các Cấp sau
// đó không ai ký. Vẽ MỜ + giữ trạng thái ○ thay vì bịa ✓ cho cả cây
// (`stepStatus` trả 'Done' cho MỌI Bước khi `isIssued`).
const skipped = isIssued && c.endedByLevelFinalize === true && !hasSigned
const ls: NodeStatus = skipped ? 'Pending' : levelStatus(idx, order)
return (
<li key={`${step.id}-${order}`} className="text-[11px]">
<li key={`${step.id}-${order}`} className={cn('text-[11px]', skipped && 'opacity-50')}>
<div className="flex items-start gap-1.5">
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
{STATUS_ICON[ls]}
@ -237,6 +318,21 @@ export function ContractWorkflowPanel({
{group[0]?.name || `Cấp ${order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
{skipped && <span className="ml-1.5 text-[10px] font-normal text-slate-500">không phải qua</span>}
{/* Badge Cấp-kết-thúc. CHỮ khác KHKK ("⚑ Duyệt thay CEO") có chủ
đích: HĐ không có trạm CEO — kết thúc ở đây nghĩa là HĐ PHÁT
HÀNH luôn + sinh mã (`ContractWorkflowService.cs:527-534`).
`title` cắm trên `<span>` KHÔNG disabled ⇒ tooltip bật thật
(bài S182), và chữ "⚑ Cấp kết thúc" đã nhìn-thấy-được nên
thông tin phân biệt không nằm sau hover. */}
{finalizeHere && (
<span
className="ml-1.5 inline-flex items-center rounded bg-violet-100 px-1.5 py-0.5 text-[9px] font-semibold text-violet-800"
title="Cấp này được uỷ quyền KẾT THÚC quy trình: duyệt ở đây là HĐ phát hành luôn, không trình tiếp Bước/Cấp sau. Người duyệt vẫn bỏ tích được để trình tiếp như thường."
>
Cấp kết thúc
</span>
)}
</div>
{/* Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn lại mờ. */}
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
@ -369,6 +465,30 @@ export function ContractWorkflowPanel({
từ Bước 1 · Cấp 1.
</div>
)}
{/* [WQ3b S190 · YC-030 Q3] Ô-tích "Cấp này KẾT THÚC quy trình" — khuôn
`KhkkWorkflowPanel.tsx:665-679`. CHỈ hiện khi Cấp BE-sẽ-chấm bật cờ
(`approverFinalizeEligible`), không hiện theo "nhóm có ai đó bật". Hậu quả HĐ nặng
hơn KHKK nên nói thẳng: kết thúc ở đây là PHÁT HÀNH + sinh mã HĐ, không lùi được. */}
{action === 'approve' && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5"
checked={applyLevelFinalize}
onChange={e => setApplyLevelFinalize(e.target.checked)}
/>
<span>
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
{applyLevelFinalize
? 'Đã tích — duyệt xong HĐ chuyển thẳng "Đã phát hành" (sinh mã HĐ), KẾT THÚC tại cấp này, KHÔNG trình tiếp cấp sau.'
: 'Mặc định: duyệt xong HĐ TRÌNH TIẾP lên cấp sau của quy trình. TÍCH vào nếu muốn kết thúc ngay tại cấp này.'}
</span>
</span>
</label>
</div>
)}
<Label>
Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label>

View File

@ -4,6 +4,7 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { api } from '@/lib/api'
import type { ContractDetail } from '@/types/contracts'
@ -27,7 +28,14 @@ export function ContractDetailPage() {
<div className="lg:col-span-2">
<ContractDetailContent contract={detail.data} onBack={() => navigate(-1)} />
</div>
<aside>
<aside className="space-y-4">
{/* [WQ3b S190 · YC-030 Q5] Máy duyệt V2 dời từ ContractDetailContent ra host —
vị trí cột-cạnh khuôn KHKK, không nhân đôi bộ nút. */}
{detail.data.approvalWorkflowId != null && (
<section className="rounded-lg border border-brand-200 bg-white p-5">
<ContractWorkflowPanel contract={detail.data} />
</section>
)}
<WorkflowHistoryPanel contract={detail.data} />
</aside>
</div>

View File

@ -16,6 +16,7 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Search } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { PhaseBadge } from '@/components/PhaseBadge'
@ -263,7 +264,17 @@ export function ContractsListPage() {
{/* [gate F-1/F-2] Panel-3 TỰ CUỘN ⇒ thân cây `lg:max-h-none` giữ đúng 1 thanh cuộn;
sticky/self-start của khuôn vô hiệu ở host display:block này — by-design. */}
{!selectedId && <PipelineTreePanel currentStage={3} bodyMaxHClass="max-h-[28rem] lg:max-h-none" />}
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
{selectedId && detail.data && (
<div className="space-y-4">
{/* [WQ3b S190 · YC-030 Q5] Máy duyệt V2 dời từ ContractDetailContent ra host. */}
{detail.data.approvalWorkflowId != null && (
<section className="rounded-lg border border-brand-200 bg-white p-5">
<ContractWorkflowPanel contract={detail.data} />
</section>
)}
<WorkflowHistoryPanel contract={detail.data} />
</div>
)}
</aside>
</div>
</div>

View File

@ -184,6 +184,41 @@ export type ContractDetail = {
currentWorkflowStepIndex?: number | null
/** Cây quy trình THÔ (xem `ContractWorkflowStep`). */
workflowSteps?: ContractWorkflowStep[] | null
// ===== [WQ3b S190 · YC-030 Q3] 3 field meta/kết-thúc-sớm (BE `ContractDtos.cs:103-105`) =====
// Khai `?` theo ĐÚNG quy ước 2-nghĩa của khối trên: `undefined` = bundle/cache CHƯA có WQ3-BE.
/**
* Mã QUY TRÌNH V2 đã pin (`HD-TP`, `HD-NCC`…) — 🔴 KHÔNG phải `maHopDong`, hai thứ cùng
* gọi là "mã". `null` khi HĐ chưa pin V2 (V1 legacy) ⇒ panel không vẽ dòng meta.
*/
workflowCode?: string | null
/** Số phiên bản quy trình đã pin — hiển thị `v{NN}` (pad 2). */
workflowVersion?: number | null
/**
* CỜ RUNTIME (BE Mig 73, write-site duy nhất `ContractWorkflowService.cs:529`): HĐ này ĐÃ
* kết thúc sớm tại một Cấp được uỷ quyền. 🔴 KHÔNG suy từ `allowApproverFinalize` của cây —
* cờ cây là CẤU HÌNH (được phép), cờ này là VIỆC ĐÃ XẢY RA (bài #78 / PE Mig 60).
* BE non-nullable (V1 legacy = `false`); `?` ở đây chỉ để chịu payload cũ.
*/
endedByLevelFinalize?: boolean
}
/**
* [WQ3b S190 · YC-030 Q3] Body `POST /contracts/{id}/transitions` — mirror BE
* `TransitionContractBody` (`ContractsController.cs:269-274`).
* 🔴 2 field cuối OMIT-ĐƯỢC và omit KHÔNG đồng nghĩa `false`:
* • `applyLevelFinalize` bỏ trống ⇒ controller `?? true` (`:71`) ⇒ Cấp có
* `allowApproverFinalize` sẽ KẾT THÚC quy trình. Muốn opt-out phải gửi `false` TƯỜNG MINH.
* • `skipToFinal` bỏ trống ⇒ `false` (mặc định record BE). FE hiện KHÔNG có đường bấm cho cờ
* này (nó là F2/Mig 31, cần `allowApproverSkipToFinal` mà DTO cây chưa trả) — khai ở đây để
* nơi gọi thấy đủ hình dạng API, KHÔNG phải để dùng ngay.
*/
export type TransitionContractBody = {
targetPhase: number
decision: number
comment: string | null
applyLevelFinalize?: boolean
skipToFinal?: boolean
}
// [W2 S187 · YC-023] Khối "Nguồn gốc KHKK" — BE reverse-join `ContractSigningPlanLines