[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 { SlaTimer } from '@/components/SlaTimer'
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection' import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' 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 // [WQ3b S190 · YC-030 Q5] 🧊 Import `ContractWorkflowPanel` (W6/S187) ĐÃ GỠ khỏi file này —
// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy. // máy duyệt dời sang PANEL-3 của từng host, xem khối lineage ở `:isV2`.
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
@ -133,13 +132,23 @@ export function ContractDetailContent({
// duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`) // 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 // 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. // đọ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, // • 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ờ). // 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`) // • 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. // ⇒ 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 // 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 // (đườ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 const isV2 = c.approvalWorkflowId != null
// ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) ===================================== // ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) =====================================
@ -187,8 +196,9 @@ export function ContractDetailContent({
<PhaseBadge phase={c.phase} /> <PhaseBadge phase={c.phase} />
</div> </div>
</div> </div>
{/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở {/* [W6 S187 · sửa WQ3b S190] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2
`ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */} 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 && ( {!isV2 && availableTargets.length > 0 && (
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}> <Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
@ -321,18 +331,10 @@ export function ContractDetailContent({
</section> </section>
)} )}
{/* [W6 S187 · YC-023 GĐ3] Màn duyệt V2-trạm — nửa V2 của dual-render. {/* [WQ3b S190 · YC-030 Q5] Ở ĐÂY TRƯỚC LÀ máy duyệt V2-trạm (W6/S187) — đã dời sang
🔴 ĐỨ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 panel-3 của từng host theo khuôn KHKK; lý do + danh sách host xem khối `isV2` ở trên.
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ì 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
đú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 KHKK → góp ý → file → chi tiết) là thứ tự cũ, không xáo thêm trong cùng một lượt. */}
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>
)}
<section className="rounded-lg border border-slate-200 bg-white p-5"> <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"> <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 // 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". // 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) ═════════════ // ══ [WQ3b S190 · YC-030 Q3] 3 MẢNH KHUÔN KHKK — TỪ CHỐI @S187, PORT ĐỦ ĐỢT NÀY ════════
// 1. ❌ Ô-tích "Cấp này KẾT THÚC quy trình": `TransitionContractBody` có ĐÚNG 3 field; // 🧊 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
// `skipToFinal` là tham số C# default (`TransitionAsync(..., bool skipToFinal=false)`) // 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
// KHÔNG nằm trên API surface ⇒ vẽ ô-tích = control chết, tích xong không đổi gì. // 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:
// 2. ❌ Badge "⚑ Duyệt thay CEO" cho Cấp có `allowApproverFinalize`: `ApproveV2Async` của HĐ // 1. ✅ Ô-tích "Cấp này KẾT THÚC quy trình" — `TransitionContractBody` nay có
// KHÔNG ĐỌC cờ đó lần nào (`ContractWorkflowService.cs:230-407` — cờ duy nhất nó đọc là // `ApplyLevelFinalize`/`SkipToFinal` (`ContractsController.cs:269-274`), `?? true` ở
// `AllowApproverSkipToFinal` `:335`, cho đường skip mà FE không gọi được). DTO vẫn trả // `:71`. Trước đây vẽ ô-tích là control CHẾT; nay tích/bỏ-tích đổi thật đường đi.
// field (`ContractDtos.cs:138`) và chú thích W2 ghi rõ "dùng hay không là quyết của W6". // 🔴 Ô-tích là OPT-OUT: BE cần CẢ HAI vế `AllowApproverFinalize ∧ applyLevelFinalize`
// 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. // (`ContractWorkflowService.cs:527`), mà bỏ trống ⇒ `true`. Vì vậy panel gửi giá trị
// 3. ❌ Banner kết-thúc-sớm + meta `{code} v{NN} · {name}`: `ContractDetail` 0 field // TƯỜNG MINH mỗi lần duyệt, không dựa vào mặc định của server.
// `endedByLevelFinalize`/`workflowCode`/`workflowVersion`/`workflowName`. Mượn // 2. ✅ Badge cho Cấp có `allowApproverFinalize` — `ApproveV2Async` NAY ĐỌC cờ đó (`:527`)
// `c.workflow.policyName` là ghép nhãn của workflow-policy V1 vào quy trình V2 — hai // ⇒ 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
// vật khác nhau. Cây thô đã INLINE trong detail ⇒ cũng KHÔNG gọi thêm // KHKK ("⚑ Duyệt thay CEO"): quy trình HĐ không có trạm CEO nào để "thay" — terminal
// `/approval-workflows-v2/{id}` ("KHÔNG đẻ đường thứ 3" — `ContractDtos.cs:86-88`). // 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 ════════════════════════════════════ // ══ 2 KHỐI BỎ VÌ TRÙNG CHỖ ĐÃ CÓ TRÊN CÙNG TRANG ════════════════════════════════════
// Cả 4 host (`ContractDetailPage` ×2 app · `MyContractsPage` · `ContractsListPage` · // Cả 4 host (`ContractDetailPage` ×2 app · `MyContractsPage` · `ContractsListPage` ·
@ -62,6 +68,7 @@ import {
type ContractDetail, type ContractDetail,
type ContractLevelOpinion, type ContractLevelOpinion,
type ContractWorkflowLevel, type ContractWorkflowLevel,
type TransitionContractBody,
} from '@/types/contracts' } from '@/types/contracts'
type NodeStatus = 'Done' | 'Current' | 'Pending' type NodeStatus = 'Done' | 'Current' | 'Pending'
@ -102,6 +109,10 @@ export function ContractWorkflowPanel({
const { user } = useAuth() const { user } = useAuth()
const [action, setAction] = useState<PanelAction | null>(null) const [action, setAction] = useState<PanelAction | null>(null)
const [comment, setComment] = useState('') 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 steps = c.workflowSteps ?? []
const curStepIdx = c.currentWorkflowStepIndex const curStepIdx = c.currentWorkflowStepIndex
@ -154,12 +165,43 @@ export function ContractWorkflowPanel({
const actorInLevel = isAdmin || actorIsCurrentApprover const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel 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({ const transition = useMutation({
mutationFn: async (a: PanelAction) => { mutationFn: async (a: PanelAction) => {
// Xem khối WIRE ở đầu file: cặp (targetPhase, decision) LÀ thứ chọn nhánh trong // 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. // `TransitionAsync`, không phải một tên endpoint riêng.
const body = a === 'approve' // [WQ3b S190 · YC-030 Q3] `applyLevelFinalize` CHỈ gắn ở nhánh duyệt, và GẮN TƯỜNG MINH
? { targetPhase: ContractPhase.ChoDuyet, decision: ApprovalDecision.Approve, comment: comment.trim() || null } // 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 } : { targetPhase: ContractPhase.TraLai, decision: ApprovalDecision.Reject, comment: comment.trim() || null }
await api.post(`/contracts/${c.id}/transitions`, body) 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.') toast.success(a === 'approve' ? 'Đã duyệt cấp hiện tại.' : 'Đã trả lại người soạn sửa.')
setAction(null) setAction(null)
setComment('') 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, // `['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 // `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. // 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* ── 1. Header ───────────────────────────────────────────────────────── */} {/* ── 1. Header + meta {code} v{NN} ───────────────────────────────────── */}
<div> <div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3> <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"> <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 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ế. người duyệt sang Cấp kế.
</p> </p>
</div> </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 ────────────────────────────────────────── */} {/* ── 2. Sơ đồ Bước → Cấp → NV ────────────────────────────────────────── */}
{steps.length > 0 ? ( {steps.length > 0 ? (
<ol className="space-y-2"> <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"> <ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{orders.map(order => { {orders.map(order => {
const group = step.levels.filter(l => l.order === 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 ( 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"> <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])}> <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]} {STATUS_ICON[ls]}
@ -237,6 +318,21 @@ export function ContractWorkflowPanel({
{group[0]?.name || `Cấp ${order}`} {group[0]?.name || `Cấp ${order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>} {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>} {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> </div>
{/* Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn lại mờ. */} {/* 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"> <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. từ Bước 1 · Cấp 1.
</div> </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> <Label>
Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'} Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label> </Label>

View File

@ -4,6 +4,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { ContractDetail } from '@/types/contracts' import type { ContractDetail } from '@/types/contracts'
@ -27,7 +28,14 @@ export function ContractDetailPage() {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<ContractDetailContent contract={detail.data} onBack={() => navigate(-1)} /> <ContractDetailContent contract={detail.data} onBack={() => navigate(-1)} />
</div> </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} /> <WorkflowHistoryPanel contract={detail.data} />
</aside> </aside>
</div> </div>

View File

@ -16,6 +16,7 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Search } from 'lucide-react' import { FileText, Search } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { PhaseBadge } from '@/components/PhaseBadge' 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; {/* [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. */} 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 && <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> </aside>
</div> </div>
</div> </div>

View File

@ -184,6 +184,41 @@ export type ContractDetail = {
currentWorkflowStepIndex?: number | null currentWorkflowStepIndex?: number | null
/** Cây quy trình THÔ (xem `ContractWorkflowStep`). */ /** Cây quy trình THÔ (xem `ContractWorkflowStep`). */
workflowSteps?: ContractWorkflowStep[] | null 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 // [W2 S187 · YC-023] Khối "Nguồn gốc KHKK" — BE reverse-join `ContractSigningPlanLines

View File

@ -12,9 +12,8 @@ import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer' import { SlaTimer } from '@/components/SlaTimer'
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection' import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' 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 // [WQ3b S190 · YC-030 Q5] 🧊 Import `ContractWorkflowPanel` (W6/S187) ĐÃ GỠ khỏi file này —
// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy. // máy duyệt dời sang PANEL-3 của từng host, xem khối lineage ở `:isV2`.
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
@ -133,13 +132,23 @@ export function ContractDetailContent({
// duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`) // 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 // 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. // đọ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, // • 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ờ). // 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`) // • 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. // ⇒ 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 // 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 // (đườ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 const isV2 = c.approvalWorkflowId != null
// ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) ===================================== // ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) =====================================
@ -187,8 +196,9 @@ export function ContractDetailContent({
<PhaseBadge phase={c.phase} /> <PhaseBadge phase={c.phase} />
</div> </div>
</div> </div>
{/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở {/* [W6 S187 · sửa WQ3b S190] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2
`ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */} 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 && ( {!isV2 && availableTargets.length > 0 && (
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}> <Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
@ -321,18 +331,10 @@ export function ContractDetailContent({
</section> </section>
)} )}
{/* [W6 S187 · YC-023 GĐ3] Màn duyệt V2-trạm — nửa V2 của dual-render. {/* [WQ3b S190 · YC-030 Q5] Ở ĐÂY TRƯỚC LÀ máy duyệt V2-trạm (W6/S187) — đã dời sang
🔴 ĐỨ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 panel-3 của từng host theo khuôn KHKK; lý do + danh sách host xem khối `isV2` ở trên.
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ì 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
đú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 KHKK → góp ý → file → chi tiết) là thứ tự cũ, không xáo thêm trong cùng một lượt. */}
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>
)}
<section className="rounded-lg border border-slate-200 bg-white p-5"> <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"> <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">

View File

@ -0,0 +1,346 @@
// [S190 · WQ1-FE YC-030 Q1] PANEL 1 của màn "HĐ của tôi" (GĐ3) — mirror-ADAPT
// `components/khkk/KhkkListPanel.tsx` (GĐ2): khối filter trên cùng + thân CÂY-hoặc-PHẲNG
// cuộn riêng + chân đếm. Anh BÁC bản hoán-đổi W2 (cây trái ⟂ danh-sách phải) và chốt
// "Q1 như KHKK" ⇒ hai khối đó gộp làm MỘT cột, đổi nhau theo bộ lọc.
//
// 🔴 fe-user-ONLY CÓ CHỦ ĐÍCH — KHÔNG có cặp mirror ở fe-admin, đừng đi tìm rồi tưởng ai đó
// quên `cp`: `ls fe-admin/src/pages/contracts/` = ContractCreatePage · ContractDetailPage ·
// ContractsListPage · HardCopiesPage — KHÔNG có `MyContractsPage`. Bên admin đối ứng là
// `ContractsListPage.tsx` (khác file, khác vai: quản-trị toàn bộ HĐ, không phải "HĐ của tôi").
// Đây là chỗ 2 app LỆCH THẬT, khác với 4 cặp component/hook đang SHA-identical.
//
// ══ HAI CHẾ ĐỘ, MỘT CỘT ═══════════════════════════════════════════════════════════════════
// CÂY (mặc định) `PipelineTreePanel currentStage={3}` — Năm Dự án Hạng mục HĐ.
// PHẲNG khi CÓ bất kỳ trục lọc nào: từ khoá · trạng thái · `pendingMe` · `deleted`.
//
// 🔴 [M-1 — bê nguyên từ `KhkkListPanel.tsx:14`] `!canPe` cũng ép PHẲNG. Cây dựng trên xương
// sống phiếu Duyệt NCC (`usePipelineStages.ts:83` + index `enabled: canPe`); ai KHÔNG có
// quyền Duyệt NCC thì cây dựng ra RỖNG trong khi `GET /contracts` vẫn cho họ đọc HĐ của
// chính mình (mặt còn lại của gotcha #82). Bỏ vế này = họ mất trắng danh sách, hỏng CÂM.
//
// 🔴 [HĐ NGOÀI GÓI — khai lại, W2 `MyContractsPage.tsx:9-10` đã ghi và vẫn ĐÚNG] Cây chỉ mọc
// từ phiếu Duyệt NCC ⇒ HĐ tạo TRỰC TIẾP (không qua gói thầu) KHÔNG BAO GIỜ hiện trên cây.
// Trước đợt này danh-sách là một cột thường-trực nên luôn thấy; nay nó là một CHẾ ĐỘ ⇒
// đường thấy = bật bất kỳ bộ lọc nào (chọn trạng thái là đủ). Vì đường đó không tự lộ ra,
// có DÒNG CHỮ HIỆN ở cuối khối filter khi đang ở chế độ cây — KHÔNG giấu sau tooltip
// (bài S182: `title` trên control disabled bị Chromium nuốt; và thông tin PHÂN BIỆT thì
// phải nhìn thấy được).
import { useQuery } from '@tanstack/react-query'
import { FileText, Search } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
import type { Paged } from '@/types/master'
// Cửa sổ tải của chế độ PHẲNG. Giữ nguyên số của bản W2/W7 (`pageSize: 100`, trang 1, KHÔNG
// có UI phân trang) — đợt này chỉ đổi CHỖ ĐỨNG của danh sách, không đổi cách phân trang.
// Chân cột khai thẳng "hiển thị x/total" khi bị cắt, thay vì cắt im lặng.
const PAGE_SIZE = 100
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
/**
* Card 3 hàng — khuôn `KhkkPlanCard` (`KhkkListPanel.tsx:103-191`), thay 5 ô của HĐ vào:
* tên · mã · NCC · chip phase · giá (+ đồng hồ SLA vốn có của màn này).
* Markup giữ NGUYÊN của bản W2 (`MyContractsPage.tsx:333-359` trước đợt này) — dời chỗ chứ
* không vẽ lại, để không đổi hình một cách âm thầm.
*/
export function ContractRowCard({
contract: c,
selected,
onSelect,
disabled,
}: {
contract: ContractListItem
selected: boolean
onSelect: (id: string) => void
/** Chế độ "Đã xóa": chỉ liệt kê, không mở (detail endpoint không IgnoreQueryFilters ⇒ 404). */
disabled?: boolean
}) {
return (
<button
type="button"
onClick={() => { if (!disabled) onSelect(c.id) }}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
selected && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
disabled && 'cursor-default opacity-70 hover:bg-transparent',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">
{c.tenHopDong ?? '(chưa đặt tên)'}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{fmtMoney(c.giaTri)}</span>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
)
}
export function ContractListPanel({
selectedId,
onSelect,
search,
onSearchChange,
type,
onTypeChange,
phase,
onPhaseChange,
pendingMe,
deletedView,
}: {
selectedId: string | null
onSelect: (id: string) => void
search: string
onSearchChange: (v: string) => void
/** Loại HĐ ĐANG ÁP = `?type=` của leaf menu, có thể bị `Select` bên dưới đè (host giải). */
type: number | null
onTypeChange: (v: number | null) => void
/** Trạng thái ĐANG ÁP = `?phase=` của leaf, có thể bị `Select` đè (host giải). */
phase: number | null
onPhaseChange: (v: number | null) => void
pendingMe: boolean
deletedView: boolean
}) {
const { canPe } = usePipelineStages()
const term = search.trim()
// 🔴 Thứ tự vế KHÔNG tuỳ tiện: `!canPe` đứng đầu vì nó là lưới an toàn (M-1), 4 vế sau là
// "người dùng đang lọc" ⇒ lọc thì phải thấy DANH SÁCH, cây không diễn tả được bộ lọc.
const flatMode = !canPe || term.length > 0 || phase !== null || pendingMe || deletedView
const list = useQuery({
// Trục key giữ ĐÚNG 6 trục của bản W3 (`MyContractsPage.tsx:87`) — mỗi trục đổi request
// hoặc đổi hẳn endpoint, thiếu một trục là dùng chung ô cache sai CÂM. Khác duy nhất:
// dùng `term` (đã trim) thay `search` thô ⇒ " abc" và "abc" về chung một ô, cùng payload.
queryKey: ['my-contracts', type, phase, deletedView, term, pendingMe],
queryFn: async () => {
// [S190 W3 — khuôn PE `PurchaseEvaluationsListPage.tsx:77-99`] Nhánh "Đã xóa" đứng
// TRƯỚC `pendingMe`: 2 view LOẠI TRỪ nhau, và HĐ đã xóa chỉ thấy được qua endpoint
// riêng `/contracts/deleted`. Đảo thứ tự = URL `?deleted=1&pendingMe=1` rơi vào inbox
// (inbox không bao giờ trả HĐ đã xóa) ⇒ màn "Đã xóa" rỗng THẦM LẶNG.
if (pendingMe && !deletedView) {
// 🔴 ĐỔI ENDPOINT, KHÔNG gửi thêm param: `GET /contracts` KHÔNG có param `pendingMe`
// và ASP.NET NUỐT param lạ IM LẶNG ⇒ gửi lên sẽ ra Y HỆT danh sách thường.
// `GET /contracts/inbox` = 0 THAM SỐ, trả `List<ContractListItemDto>` (không phân
// trang) → lọc loại ở CLIENT + ép shape.
// [carry:w7b-inbox-param] Nợ W7 vẫn treo: `GetMyInboxQuery` là record 0 tham số và
// còn `Take(100)` cứng ⇒ chưa lọc/tìm được phía máy chủ. Nâng BE = việc đợt khác.
const res = await api.get<ContractListItem[]>('/contracts/inbox')
const items = type != null ? res.data.filter(c => c.type === type) : res.data
return { items, total: items.length }
}
const res = await api.get<Paged<ContractListItem>>(
deletedView ? '/contracts/deleted' : '/contracts',
{
params: {
page: 1,
pageSize: PAGE_SIZE,
// [W7 S187] Lọc loại + [W2 S190] tìm kiếm ĐỀU ĐI SERVER (param có sẵn ở CẢ 2
// endpoint: `ContractsController.cs:19,25` và `:34,36`; BE lọc TRƯỚC phân trang
// `ContractFeatures.cs:325` trước `Skip/Take:340`). Lọc client sau khi đã cắt
// 100 dòng = loại/mã hiếm biến mất IM LẶNG — đúng bệnh đã vá 2 đợt trước.
// 🔴 ĐỔI SO VỚI W2: `type` ở đây là loại ĐANG ÁP (đã tính cả `Select`), không
// còn là `?type=` trần của URL. `Select` loại nay lọc CẢ cây LẪN danh sách —
// để nó chỉ lọc cây thì ở chế độ phẳng nó thành nút chết.
type: type ?? undefined,
search: term || undefined,
...(!deletedView && phase != null ? { phase } : {}),
},
},
)
return { items: res.data.items, total: res.data.total }
},
// Chế độ CÂY không đọc endpoint này ⇒ đừng gọi. (Khuôn `KhkkListPanel.tsx:260`.)
enabled: flatMode,
})
const rows = list.data?.items ?? []
const total = list.data?.total ?? 0
const typeLabel = type != null ? ContractTypeLabel[type] : null
// "Rỗng vì LỌC" ⟂ "rỗng vì CHƯA CÓ GÌ" ⟂ "rỗng vì LỖI" phải khác câu (bài S190 W3): dùng
// chung một câu thì người đứng ở mục "Đang duyệt" đọc thành "loại HĐ này chưa có dữ liệu".
const emptyTitle = pendingMe && !deletedView ? 'Không có HĐ chờ bạn duyệt' : 'Không có HĐ'
const emptyDescription = deletedView
? 'Chưa có HĐ nào bị xóa.'
: pendingMe
? typeLabel
? `Không HĐ "${typeLabel}" nào đang đến lượt bạn duyệt.`
: 'Không có HĐ nào đang đến lượt bạn duyệt.'
: term || phase !== null
? 'Không HĐ nào khớp bộ lọc đang bật.'
: typeLabel
? 'Loại HĐ này chưa có dữ liệu.'
: 'Tạo HĐ mới để bắt đầu.'
return (
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
{/* Khối filter — CHỖ ĐẶT + thứ tự giống KHKK/PE (trên cùng panel 1).
Q2 anh chốt: ô chọn ở đây CHỈ có "theo loại HĐ" (không đẻ trục chọn thứ hai). */}
<div className="space-y-2 border-b border-slate-200 p-3">
<Select
value={type ?? ''}
aria-label="Lọc loại HĐ"
onChange={e => onTypeChange(e.target.value === '' ? null : Number(e.target.value))}
>
{/* Ca URL trần (`/my-contracts` không `?type=`) phải có lựa chọn hợp lệ. */}
<option value="">Tất cả loại </option>
{Object.entries(ContractTypeLabel).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={pendingMe ? '' : search}
onChange={e => onSearchChange(e.target.value)}
disabled={pendingMe}
placeholder="Tìm theo mã / tên / NCC…"
className="pl-8"
/>
</div>
{/* [S176-KHKK khuôn `KhkkListPanel.tsx:347-350`] Thùng rác ⇒ THAY dropdown bằng banner.
Ẩn dropdown là CÓ LÝ DO: HĐ đã xóa giữ NGUYÊN phase lúc bị xóa, `IsDeleted` mới là
thứ phân loại ⇒ lọc trạng thái ở đây là nút chết (và `queryFn` cũng cố ý KHÔNG gửi
`phase` cho `/contracts/deleted`). */}
{deletedView ? (
<div className="rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[11px] text-red-700">
🗑 <strong> đã xóa</strong> chỉ xem. Không khôi phục, không thao tác.
</div>
) : pendingMe ? (
// [S190 W3] Nói VÌ SAO bằng CHỮ HIỆN, KHÔNG bằng `title` (tooltip trên control
// `disabled` bị Chromium nuốt — bài S182). Ô tìm ở trên cũng khoá vì cùng một lý do:
// `/contracts/inbox` là record 0 tham số, không nhận `search` lẫn `phase`.
<p className="text-[11px] leading-snug text-slate-500">
Chế đ Đang duyệt đc hộp thư đến chưa hỗ trợ tìm kiếm / lọc trạng thái phía máy chủ.
</p>
) : (
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả trạng thái</option>
{Object.entries(ContractPhaseLabel).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
)}
{/* Chữ HIỆN cho đường tới HĐ ngoài gói (xem khối đầu file). Chỉ hiện ở chế độ cây —
đang ở danh sách phẳng rồi thì câu này thành thừa. */}
{!flatMode && (
<p className="text-[11px] italic leading-snug text-slate-500">
Cây dựng từ phiếu Duyệt NCC tạo trực tiếp (ngoài gói thầu) không nằm trên cây.
Chọn một trạng thái hoặc ô tìm đ chuyển sang danh sách phẳng.
</p>
)}
{/* [M-1] Không có quyền Duyệt NCC ⇒ nói thẳng vì sao không thấy cây, thay vì để người
dùng đoán. Danh sách phẳng bên dưới vẫn đầy đủ. */}
{!canPe && (
<p className="text-[10px] italic text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh
sách vẫn liệt đy đ dưới.
</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!flatMode && (
// [X-3 / SF-26 — bài S168 F-1] Cột này TỰ CUỘN ⇒ thân cây PHẢI bỏ trần `max-h` mặc
// định của khuôn, nếu không là 2 thanh cuộn lồng nhau.
<PipelineTreePanel
currentStage={3}
hideHeader
hideSearch
bodyMaxHClass="lg:max-h-none"
stage2Options={{
// Bấm leaf HĐ ⇒ chọn vào Panel 2 qua `?id=`; URL Ở LẠI `/my-contracts`.
onOpenContract: onSelect,
// `null` ⇒ `undefined` ⇒ hook coi như KHÔNG lọc (đủ mọi loại).
stage3TypeFilter: type ?? undefined,
}}
/>
)}
{flatMode && list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100 motion-reduce:animate-none" />
))}
</div>
)}
{/* Rỗng-vì-LỖI phải khác rỗng-vì-không-có: bản W2 không có nhánh này nên lỗi mạng đọc
thành "Không có HĐ" — sai nghĩa hẳn. Khuôn `KhkkListPanel.tsx:416-427`. */}
{flatMode && list.isError && (
<div className="space-y-2 px-3 py-4">
<p className="text-[12px] text-slate-500">Không tải đưc danh sách .</p>
<button
type="button"
onClick={() => list.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
>
Thử lại
</button>
</div>
)}
{flatMode && !list.isLoading && !list.isError && rows.length === 0 && (
<div className="p-6">
<EmptyState icon={FileText} title={emptyTitle} description={emptyDescription} />
</div>
)}
{flatMode && rows.length > 0 && (
<ul className="divide-y divide-slate-100">
{rows.map(c => (
<li key={c.id}>
<ContractRowCard
contract={c}
selected={selectedId === c.id}
onSelect={onSelect}
disabled={deletedView}
/>
</li>
))}
</ul>
)}
</div>
{/* Chân cột — số đếm dời từ badge ở header trang xuống đây (khuôn `KhkkListPanel.tsx:457`).
🔴 Nói THẬT về chỗ bị cắt: cửa sổ là 100 dòng và trang này chưa có UI phân trang, nên
`total > rows.length` phải hiện thành chữ, không im lặng. */}
{flatMode && !list.isLoading && !list.isError && rows.length > 0 && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-t border-slate-200 px-3 py-2 text-[11px] text-slate-500">
<span>
<FileText className="mr-1 inline h-3 w-3" />
{total > rows.length ? `${rows.length} / ${total}` : `${rows.length}`}
</span>
{total > rows.length && (
<span className="text-amber-700">Chỉ tải {PAGE_SIZE} dòng đu thu hẹp bộ lọc đ thấy phần còn lại.</span>
)}
</div>
)}
</aside>
)
}

View File

@ -23,20 +23,26 @@
// phân 2 ngả bằng ĐÚNG `targetPhase`: `== TuChoi` ⇒ TuChoi, else ⇒ TraLai. Gửi 98 tường // 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". // 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) ═════════════ // ══ [WQ3b S190 · YC-030 Q3] 3 MẢNH KHUÔN KHKK — TỪ CHỐI @S187, PORT ĐỦ ĐỢT NÀY ════════
// 1. ❌ Ô-tích "Cấp này KẾT THÚC quy trình": `TransitionContractBody` có ĐÚNG 3 field; // 🧊 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
// `skipToFinal` là tham số C# default (`TransitionAsync(..., bool skipToFinal=false)`) // 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
// KHÔNG nằm trên API surface ⇒ vẽ ô-tích = control chết, tích xong không đổi gì. // 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:
// 2. ❌ Badge "⚑ Duyệt thay CEO" cho Cấp có `allowApproverFinalize`: `ApproveV2Async` của HĐ // 1. ✅ Ô-tích "Cấp này KẾT THÚC quy trình" — `TransitionContractBody` nay có
// KHÔNG ĐỌC cờ đó lần nào (`ContractWorkflowService.cs:230-407` — cờ duy nhất nó đọc là // `ApplyLevelFinalize`/`SkipToFinal` (`ContractsController.cs:269-274`), `?? true` ở
// `AllowApproverSkipToFinal` `:335`, cho đường skip mà FE không gọi được). DTO vẫn trả // `:71`. Trước đây vẽ ô-tích là control CHẾT; nay tích/bỏ-tích đổi thật đường đi.
// field (`ContractDtos.cs:138`) và chú thích W2 ghi rõ "dùng hay không là quyết của W6". // 🔴 Ô-tích là OPT-OUT: BE cần CẢ HAI vế `AllowApproverFinalize ∧ applyLevelFinalize`
// 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. // (`ContractWorkflowService.cs:527`), mà bỏ trống ⇒ `true`. Vì vậy panel gửi giá trị
// 3. ❌ Banner kết-thúc-sớm + meta `{code} v{NN} · {name}`: `ContractDetail` 0 field // TƯỜNG MINH mỗi lần duyệt, không dựa vào mặc định của server.
// `endedByLevelFinalize`/`workflowCode`/`workflowVersion`/`workflowName`. Mượn // 2. ✅ Badge cho Cấp có `allowApproverFinalize` — `ApproveV2Async` NAY ĐỌC cờ đó (`:527`)
// `c.workflow.policyName` là ghép nhãn của workflow-policy V1 vào quy trình V2 — hai // ⇒ 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
// vật khác nhau. Cây thô đã INLINE trong detail ⇒ cũng KHÔNG gọi thêm // KHKK ("⚑ Duyệt thay CEO"): quy trình HĐ không có trạm CEO nào để "thay" — terminal
// `/approval-workflows-v2/{id}` ("KHÔNG đẻ đường thứ 3" — `ContractDtos.cs:86-88`). // 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 ════════════════════════════════════ // ══ 2 KHỐI BỎ VÌ TRÙNG CHỖ ĐÃ CÓ TRÊN CÙNG TRANG ════════════════════════════════════
// Cả 4 host (`ContractDetailPage` ×2 app · `MyContractsPage` · `ContractsListPage` · // Cả 4 host (`ContractDetailPage` ×2 app · `MyContractsPage` · `ContractsListPage` ·
@ -62,6 +68,7 @@ import {
type ContractDetail, type ContractDetail,
type ContractLevelOpinion, type ContractLevelOpinion,
type ContractWorkflowLevel, type ContractWorkflowLevel,
type TransitionContractBody,
} from '@/types/contracts' } from '@/types/contracts'
type NodeStatus = 'Done' | 'Current' | 'Pending' type NodeStatus = 'Done' | 'Current' | 'Pending'
@ -102,6 +109,10 @@ export function ContractWorkflowPanel({
const { user } = useAuth() const { user } = useAuth()
const [action, setAction] = useState<PanelAction | null>(null) const [action, setAction] = useState<PanelAction | null>(null)
const [comment, setComment] = useState('') 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 steps = c.workflowSteps ?? []
const curStepIdx = c.currentWorkflowStepIndex const curStepIdx = c.currentWorkflowStepIndex
@ -154,12 +165,43 @@ export function ContractWorkflowPanel({
const actorInLevel = isAdmin || actorIsCurrentApprover const actorInLevel = isAdmin || actorIsCurrentApprover
const blockedByLevel = isWaiting && !actorInLevel 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({ const transition = useMutation({
mutationFn: async (a: PanelAction) => { mutationFn: async (a: PanelAction) => {
// Xem khối WIRE ở đầu file: cặp (targetPhase, decision) LÀ thứ chọn nhánh trong // 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. // `TransitionAsync`, không phải một tên endpoint riêng.
const body = a === 'approve' // [WQ3b S190 · YC-030 Q3] `applyLevelFinalize` CHỈ gắn ở nhánh duyệt, và GẮN TƯỜNG MINH
? { targetPhase: ContractPhase.ChoDuyet, decision: ApprovalDecision.Approve, comment: comment.trim() || null } // 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 } : { targetPhase: ContractPhase.TraLai, decision: ApprovalDecision.Reject, comment: comment.trim() || null }
await api.post(`/contracts/${c.id}/transitions`, body) 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.') toast.success(a === 'approve' ? 'Đã duyệt cấp hiện tại.' : 'Đã trả lại người soạn sửa.')
setAction(null) setAction(null)
setComment('') 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, // `['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 // `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. // 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* ── 1. Header ───────────────────────────────────────────────────────── */} {/* ── 1. Header + meta {code} v{NN} ───────────────────────────────────── */}
<div> <div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3> <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"> <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 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ế. người duyệt sang Cấp kế.
</p> </p>
</div> </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 ────────────────────────────────────────── */} {/* ── 2. Sơ đồ Bước → Cấp → NV ────────────────────────────────────────── */}
{steps.length > 0 ? ( {steps.length > 0 ? (
<ol className="space-y-2"> <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"> <ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
{orders.map(order => { {orders.map(order => {
const group = step.levels.filter(l => l.order === 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 ( 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"> <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])}> <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]} {STATUS_ICON[ls]}
@ -237,6 +318,21 @@ export function ContractWorkflowPanel({
{group[0]?.name || `Cấp ${order}`} {group[0]?.name || `Cấp ${order}`}
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>} {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>} {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> </div>
{/* Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn lại mờ. */} {/* 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"> <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. từ Bước 1 · Cấp 1.
</div> </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> <Label>
Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'} Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label> </Label>

View File

@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react' import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PhaseBadge } from '@/components/PhaseBadge' import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer' import { SlaTimer } from '@/components/SlaTimer'
@ -363,7 +364,17 @@ export function InboxPage() {
Quy trình duyệt sẽ hiện khi chọn hợp đng. Quy trình duyệt sẽ hiện khi chọn hợp đng.
</div> </div>
)} )}
{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> </aside>
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { ContractDetail } from '@/types/contracts' import type { ContractDetail } from '@/types/contracts'
@ -27,7 +28,14 @@ export function ContractDetailPage() {
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<ContractDetailContent contract={detail.data} onBack={() => navigate(-1)} /> <ContractDetailContent contract={detail.data} onBack={() => navigate(-1)} />
</div> </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} /> <WorkflowHistoryPanel contract={detail.data} />
</aside> </aside>
</div> </div>

View File

@ -1,133 +1,89 @@
// 3-panel "Danh sách HĐ" — [S190 · W2 YC-029 Đ1] HOÁN ĐỔI 2 khung theo ảnh anh gửi // 3-panel "HĐ của tôi" — [S190 · YC-030 Q1] anh BÁC bản hoán-đổi (W2 YC-029 Đ1) và chốt
// ("Bỏ phần này sang bên này nhé" — mũi tên từ cây PHẢI chỉ sang danh-sách TRÁI): // "Q1 như KHKK" ⇒ trang này nay là bản sao HÌNH của `pages/khkk/KhkkListPage.tsx`:
// Panel 1 TRÁI 400px = CÂY TOÀN TRÌNH (GĐ3) + Select loại HĐ ← trước ở Panel 3 // Panel 1 TRÁI 400px = `ContractListPanel` — CÂY toàn trình (GĐ3) ⟂ DANH SÁCH phẳng, MỘT cột
// Panel 2 GIỮA = detail content + quy trình/lịch sử duyệt của HĐ đang chọn // Panel 2 GIỮA = chi tiết HĐ (`ContractDetailContent`) / EmptyState khi chưa chọn
// Panel 3 PHẢI 360px = DANH SÁCH HĐ (search + empty-state) ← trước ở Panel 1 // Panel 3 PHẢI 360px = KHUÔN KHKK QĐ-2 — hộp dashed khi chưa chọn · quy trình + lịch sử
// Selected HĐ vẫn giữ qua URL `?id=` để bookmarkable + back/forward navigation work. // duyệt khi đã chọn (`KhkkListPage.tsx:252-277`)
// //
// 🔴 CHỈ ĐỔI CHỖ, KHÔNG XOÁ KHỐI NÀO (Q1): // 🔴 CÁI BỊ BỎ, khai thẳng để người sau không tưởng ai lỡ tay:
// • Danh-sách phải ở lại: cây chỉ dựng từ phiếu Duyệt NCC ⇒ HĐ tạo TRỰC-TIẾP ngoài gói thầu // • CỘT DANH SÁCH THƯỜNG TRỰC (panel 3 của bản W2) KHÔNG còn — nó thành CHẾ ĐỘ PHẲNG bên
// KHÔNG bao giờ hiện trên cây, danh-sách là đường DUY NHẤT tới nó. // trong panel 1. Trục nào cũng mở được chế độ đó: từ khoá · trạng thái · `?pendingMe=1` ·
// `WorkflowHistoryPanel` trước ở Panel 3 (chỗ nay là danh-sách) ⇒ dời xuống NGAY DƯỚI detail // `?deleted=1`. Đây là đường DUY NHẤT tới HĐ tạo trực tiếp ngoài gói thầu (cây chỉ mọc từ
// ở Panel 2 chứ không bỏ. Delta này plan W2 KHÔNG nêu (plan chỉ nói "panel-2 giữ detail") — // phiếu Duyệt NCC) ⇒ `ContractListPanel` có DÒNG CHỮ HIỆN chỉ đúng đường đó, không giấu.
// khai ở đây để người sau khỏi tưởng ai đó lỡ tay đánh rơi. // • Badge đếm `{rows.length}` ở header GỠ: danh sách nay không thường trực nên ở chế độ cây
// không có "số dòng" nào để đếm — số đếm xuống chân panel 1, chỉ hiện khi đang phẳng.
// • `WorkflowHistoryPanel` rời panel 2 (chỗ W2 tạm để) về panel 3 — đúng chỗ cũ trước W2
// (`git show 502f4d2e:…MyContractsPage.tsx` panel-3) và đúng khuôn KHKK.
// //
// [S190 · W3 YC-029] Panel 3 có HAI NGUỒN, chọn bằng `?pendingMe=1` của URL: // 🔴 [FLAG WQ1→WQ3b] Panel 3 CỐ Ý KHÔNG render `ContractWorkflowPanel`: nó ĐÃ nằm trong
// • mặc định → `GET /contracts` (phân trang, lọc `type`/`phase`/`search` phía server) // `ContractDetailContent.tsx:331-335` (nhánh `isV2`, W6/S187) tức panel 2 — nhét thêm ở đây
// • pendingMe → `GET /contracts/inbox` (0 tham số, trọn gói) — leaf "Đang duyệt" nay ở lại // là MỘT HĐ có HAI bộ nút Duyệt/Trả lại, đúng thứ chính khối W6 cấm ở `:140-142`. Muốn panel 3
// trang này thay vì nhảy sang `/inbox`. Xem khối `queryFn` để biết vì sao KHÔNG gửi param. // mang 2 nút như `KhkkWorkflowPanel` thì phải GỠ khối `isV2` khỏi `ContractDetailContent`
// (file mirror ×2 app) — NGOÀI ranh WQ1 (lane này chỉ được chạm `MyContractsPage` +
// `ContractListPanel`), để WQ3b quyết.
// //
// Mobile (< lg): GIỮ NGUYÊN 100% hiện trạng — chỉ danh-sách hiện, click row → fullpage // Mobile (< lg): GIỮ NGUYÊN đường cũ — panel 1 hiện ở mọi bề rộng, bấm row → fullpage
// /contracts/:id. Cây là khối DESKTOP-ONLY y như TRƯỚC hoán-đổi (nó vốn nằm trong panel // `/contracts/:id` (khuôn KHKK M-4 `KhkkListPage.tsx:126-131`); panel 2/3 `hidden lg:block`.
// `hidden lg:block`), nên đợt này không đổi một pixel nào ở < lg.
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search } from 'lucide-react' import { FileText, Plus, X } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { ContractListPanel } from '@/components/contracts/ContractListPanel'
import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import type { ContractDetail } from '@/types/contracts'
import type { Paged } from '@/types/master'
import type { ContractDetail, ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms' import { ContractTypeLabel } from '@/types/forms'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function MyContractsPage() { export function MyContractsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
// [W7 S187] `type` nay ĐI THẲNG lên BE (không còn lọc client) ⇒ phải chặn giá trị rác // [W7 S187] `type` ĐI THẲNG lên BE ⇒ phải chặn giá trị rác TRƯỚC khi lên dây:
// TRƯỚC khi lên dây: `Number('abc') = NaN` mà `NaN != null` là true ⇒ nếu không chặn thì // `Number('abc') = NaN` mà `NaN != null` là true ⇒ không chặn thì axios gửi `?type=NaN`,
// axios gửi `?type=NaN`, BE bind `ContractType?` hỏng → 400 thay vì "danh sách rỗng". // BE bind `ContractType?` hỏng → 400 thay vì "danh sách rỗng". Khuôn `HardCopiesPage.tsx:67-68`.
// Khuôn đã chạy ở `HardCopiesPage.tsx:67-68` (GĐ4) — dùng lại nguyên, không đẻ khuôn thứ 2.
const rawType = searchParams.get('type') const rawType = searchParams.get('type')
const parsedType = rawType === null ? Number.NaN : Number(rawType) const parsedType = rawType === null ? Number.NaN : Number(rawType)
const typeFilter = Number.isFinite(parsedType) && parsedType > 0 ? parsedType : null const urlType = Number.isFinite(parsedType) && parsedType > 0 ? parsedType : null
const selectedId = searchParams.get('id') const selectedId = searchParams.get('id')
const search = searchParams.get('q') ?? '' const search = searchParams.get('q') ?? ''
// [S159-đợt5] leaf 'Đã duyệt' (?phase=9 — BE /contracts có sẵn param phase) và // [S159-đợt5] leaf 'Đã duyệt' (?phase=9) và 'Đã xóa' (?deleted=1 — endpoint riêng, chỉ-xem).
// 'Đã xóa' (?deleted=1 — endpoint /contracts/deleted mới, chỉ-xem). const rawPhase = searchParams.get('phase')
const phaseFilter = searchParams.get('phase') ? Number(searchParams.get('phase')) : null const urlPhase = rawPhase !== null && /^\d{1,2}$/.test(rawPhase) ? Number(rawPhase) : null
const showDeleted = searchParams.get('deleted') === '1' const showDeleted = searchParams.get('deleted') === '1'
// [S190 W3 YC-029] leaf "Đang duyệt" nay Ở LẠI trang này (`Layout.tsx` nhánh `Pending`) // [S190 W3 YC-029] leaf "Đang duyệt" Ở LẠI trang này (`Layout.tsx` nhánh `Pending`) thay vì
// thay vì nhảy sang `/inbox`. `pendingMe` là DANH-TÍNH leaf ⇒ CẤM đưa vào // nhảy sang `/inbox`. `pendingMe` là DANH-TÍNH leaf ⇒ CẤM đưa vào `TRANSIENT_QUERY_KEYS`
// `TRANSIENT_QUERY_KEYS` (strip = 2 leaf "Danh sách"/"Đang duyệt" cùng sáng). // (strip = 2 leaf "Danh sách"/"Đang duyệt" cùng sáng).
const pendingMe = searchParams.get('pendingMe') === '1' const pendingMe = searchParams.get('pendingMe') === '1'
// [S190 W2 · Q2 nhánh-đã-chọn] Select loại HĐ ở đầu panel CÂY. Mặc định = `?type=N` của URL // ---- Lớp override do NGƯỜI DÙNG bấm, đè lên bộ lọc đến từ URL ----------------------------
// (menu loại HĐ đang đứng). 🔴 Đổi Select CHỈ đổi bộ lọc CÂY — KHÔNG navigate, KHÔNG đụng // Khuôn 3-trạng-thái của KHKK (`KhkkListPage.tsx:70-90`): `undefined` = chưa chạm ⇒ nghe URL ·
// danh-sách panel-3 (danh-sách vẫn bám `?type=` của menu, đúng danh-tính leaf W1). // `null` = user chủ động chọn "Tất cả" · số = user chọn một giá trị. Gộp `undefined` với
// `null` thì chọn "Tất cả" bị URL kéo ngược về bộ lọc cũ.
// //
// 🔴 SYNC BẰNG PHÉP DẪN XUẤT, KHÔNG BẰNG `useEffect`: override được ĐÓNG DẤU loại nó thuộc về // 🔴 RESET BẰNG PHÉP DẪN XUẤT, KHÔNG bằng `useEffect` và cũng không set-state-trong-render:
// (`forType`). URL đổi loại (bấm sang "02. HĐ Giao khoán") ⇒ dấu không khớp nữa ⇒ tự rơi về // override tự ĐÓNG DẤU cái leaf nó thuộc về (`nav`). Đổi leaf (bấm sang "02. HĐ Giao khoán"
// mặc định MỚI, không cần đồng bộ tay. Bản đầu viết `useEffect(() => setTreeType(typeFilter))` // hay sang "Đã xóa") ⇒ dấu không khớp ⇒ override tự rơi, mặc định MỚI của URL lên thay.
// và ăn ĐÚNG 1 lỗi `react-hooks/set-state-in-effect` (baseline eslint 47 → 48) — dẫn xuất vừa // Bản W2 viết `useEffect(() => setTreeType(...))` và ăn ĐÚNG 1 lỗi `react-hooks/set-state-in-effect`
// hết lỗi vừa hết cả một nhịp render thừa. // (eslint 47 → 48) — dẫn xuất vừa hết lỗi vừa bớt một nhịp render thừa.
const [treeTypeOverride, setTreeTypeOverride] = const navKey = `${rawType ?? ''}|${rawPhase ?? ''}|${showDeleted ? 'del' : ''}|${pendingMe ? 'pm' : ''}`
useState<{ forType: number | null; value: number | null } | null>(null) const [override, setOverride] = useState<{
const treeType = treeTypeOverride !== null && treeTypeOverride.forType === typeFilter nav: string
? treeTypeOverride.value type: number | null | undefined
: typeFilter phase: number | null | undefined
}>({ nav: navKey, type: undefined, phase: undefined })
const live = override.nav === navKey ? override : { type: undefined, phase: undefined }
const type = live.type !== undefined ? live.type : urlType
const phase = live.phase !== undefined ? live.phase : urlPhase
const list = useQuery({ function pickType(v: number | null) {
// `typeFilter` ĐÃ nằm trong key từ trước ⇒ đổi sang lọc server KHÔNG cần thêm trục mới; setOverride(o => ({ nav: navKey, type: v, phase: o.nav === navKey ? o.phase : undefined }))
// cache cũ (payload chưa lọc) tự hết hiệu lực vì mỗi `type` là 1 ô key riêng sẵn rồi. }
// [S190 W2] +trục `search`: ô tìm nay đi SERVER-SIDE (xem `params.search`) ⇒ mỗi từ khoá function pickPhase(v: number | null) {
// là một payload khác nhau, thiếu trục này là ăn cache của từ khoá trước. setOverride(o => ({ nav: navKey, phase: v, type: o.nav === navKey ? o.type : undefined }))
// [S190 W3] +trục `pendingMe`: 2 chế độ đọc 2 NGUỒN khác nhau (`/contracts` phân trang
// ⟂ `/contracts/inbox` trọn gói) ⇒ dùng chung ô cache là trả nhầm danh sách.
queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted, search, pendingMe],
queryFn: async () => {
// [S190 W3 — khuôn PE `PurchaseEvaluationsListPage.tsx:77-99`] Nhánh "Đã xóa" đứng
// TRƯỚC `pendingMe`: 2 view LOẠI TRỪ nhau, và HĐ đã xóa chỉ thấy được qua endpoint
// riêng `/contracts/deleted`. Đảo thứ tự = URL `?deleted=1&pendingMe=1` rơi vào inbox
// (inbox không bao giờ trả HĐ đã xóa) ⇒ màn "Đã xóa" rỗng THẦM LẶNG.
if (pendingMe && !showDeleted) {
// 🔴 ĐỔI ENDPOINT, KHÔNG gửi thêm param: `GET /contracts` KHÔNG có param `pendingMe`
// (`ContractsController.cs:17-26` — 8 param, không có nó) và ASP.NET NUỐT param lạ IM
// LẶNG ⇒ gửi lên sẽ ra Y HỆT danh sách thường, hỏng câm chứ không đỏ.
// `GET /contracts/inbox` (`ContractsController.cs:41-43`) = 0 THAM SỐ, trả
// `List<ContractListItemDto>` (không phân trang) → lọc loại ở CLIENT + ép shape.
// [carry:w7b-inbox-param] LỆCH-W7 CHẤP NHẬN ĐỢT NÀY: W7 (S187) đã chuyển lọc `type`
// lên server cho 2 nhánh kia; nhánh này vẫn lọc client vì BE chưa có param —
// `GetMyInboxQuery` là record 0 tham số và còn `Take(100)` cứng. Nâng BE = việc đợt sau.
const res = await api.get<ContractListItem[]>('/contracts/inbox')
const items = typeFilter != null ? res.data.filter(c => c.type === typeFilter) : res.data
return { items, total: items.length, page: 1, pageSize: items.length }
} }
return (
await api.get<Paged<ContractListItem>>(showDeleted ? '/contracts/deleted' : '/contracts', {
params: {
page: 1,
pageSize: 100,
// [W7 S187] Lọc loại HĐ CHUYỂN LÊN SERVER cho CẢ 2 nhánh (trước đây chỉ nhánh
// `/contracts/deleted` gửi `type`, nhánh thường lọc client sau khi cắt 100 dòng
// ⇒ user >100 HĐ thì loại hiếm biến mất IM LẶNG). Param có sẵn 2 chỗ:
// `ContractsController.cs:25` (`/contracts`) + `:34` (`/contracts/deleted`);
// BE lọc TRƯỚC phân trang (`ContractFeatures.cs:325` trước `Skip/Take:340`).
// `?? undefined` ⇒ axios bỏ hẳn key, giữ nguyên hành vi "không chọn loại".
type: typeFilter ?? undefined,
// [S190 W2] 🔴 ĐỔI HÀNH-VI CÓ CHỦ ĐÍCH: ô tìm chuyển client-side → SERVER-SIDE.
// Param `search` sẵn ở CẢ 2 endpoint (`ContractsController.cs:19` và `:36`).
// Vì sao đổi: lọc client chỉ soi trong 100 dòng đã tải ⇒ user nhiều HĐ gõ đúng mã
// vẫn ra rỗng (cùng bệnh `type` đã vá @W7 S187).
// Diện tìm RỘNG HƠN bản cũ (`ContractFeatures.cs:358-364`): mã · tên HĐ · tên NCC
// · +tên DỰ ÁN (vế client cũ không có) — rộng hơn nên không mất kết quả nào.
search: search.trim() || undefined,
...(!showDeleted && phaseFilter != null ? { phase: phaseFilter } : {}),
},
})
).data
},
})
const detail = useQuery({ const detail = useQuery({
queryKey: ['contract', selectedId], queryKey: ['contract', selectedId],
@ -135,16 +91,10 @@ export function MyContractsPage() {
enabled: !!selectedId, enabled: !!selectedId,
}) })
// [W7 S187] Vế `items.filter(c => c.type === typeFilter)` ĐÃ GỠ — BE lọc rồi.
// [S190 W2] Vế lọc `search` client-side cũng GỠ NỐT theo lý-do y hệt: giữ lại = lọc 2 lần và
// che mất lỗi nếu server trả sai (sai sẽ THẦM LẶNG). Không còn phép biến đổi nào ⇒ bỏ luôn
// `useMemo` (memo hoá một phép gán là chi phí thừa).
const rows = list.data?.items ?? []
function selectContract(id: string) { function selectContract(id: string) {
// [S159-đợt5 FLAG-2 reviewer] Màn "Đã xóa" = CHỈ XEM DANH SÁCH (khuôn PE S155 // [S159-đợt5 FLAG-2 reviewer] Màn "Đã xóa" = CHỈ XEM DANH SÁCH (khuôn PE S155): detail
// `disabled={deletedView}`): detail endpoint KHÔNG IgnoreQueryFilters ⇒ HĐ đã xóa // endpoint KHÔNG IgnoreQueryFilters ⇒ HĐ đã xóa 404 ⇒ desktop = 2 panel trắng, mobile =
// 404 ⇒ desktop = 2 panel trắng, mobile = navigate sang trang 404. Chặn tại gốc. // navigate sang trang 404. Chặn tại gốc (panel cũng đã `disabled`, đây là chốt thứ hai).
if (showDeleted) return if (showDeleted) return
// Desktop ≥ lg: cập nhật URL để render Panel 2 cạnh cây/danh-sách. // Desktop ≥ lg: cập nhật URL để render Panel 2 cạnh cây/danh-sách.
// Mobile: Panel 2 hidden → navigate fullpage /contracts/:id (UX khả dụng). // Mobile: Panel 2 hidden → navigate fullpage /contracts/:id (UX khả dụng).
@ -170,40 +120,27 @@ export function MyContractsPage() {
setSearchParams(next, { replace: true }) setSearchParams(next, { replace: true })
} }
const typeLabel = typeFilter != null ? ContractTypeLabel[typeFilter] : null // Nhãn đọc theo loại ĐANG ÁP (đã tính `Select`), không theo `?type=` trần: chọn "Tất cả loại
// HĐ" mà tiêu đề vẫn treo tên một loại là tiêu đề nói dối. Khuôn `KhkkListPage.tsx:151-153`
// [S190 W3] "Rỗng vì LỌC" ⟂ "rỗng vì CHƯA CÓ GÌ" phải khác câu: dùng chung một câu thì // (headerTitle đọc `group` đã giải, không đọc `urlGroup`).
// người đang ở mục "Đang duyệt" đọc thành "loại HĐ này chưa có dữ liệu" — sai nghĩa hẳn. const typeLabel = type != null ? ContractTypeLabel[type] : null
const emptyTitle = pendingMe && !showDeleted ? 'Không có HĐ chờ bạn duyệt' : 'Không có HĐ'
const emptyDescription = showDeleted
? 'Chưa có HĐ nào bị xóa.'
: pendingMe
? typeLabel
? `Không HĐ "${typeLabel}" nào đang đến lượt bạn duyệt.`
: 'Không có HĐ nào đang đến lượt bạn duyệt.'
: typeFilter != null
? 'Loại HĐ này chưa có dữ liệu.'
: 'Tạo HĐ mới để bắt đầu.'
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col"> <div className="flex h-[calc(100vh-4rem)] flex-col">
{/* Compact page header — chiếm ít chiều cao để 3 panel có max chỗ */} {/* Compact page header — chiếm ít chiều cao để 3 panel có max chỗ */}
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3"> <header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<FileText className="h-5 w-5 text-slate-500" /> <FileText className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900"> <h1 className="truncate text-base font-semibold tracking-tight text-slate-900">
của tôi {typeLabel && <span className="text-slate-500">· {typeLabel}</span>} của tôi {typeLabel && <span className="text-slate-500">· {typeLabel}</span>}
</h1> </h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{rows.length}
</span>
</div> </div>
{showDeleted && ( {showDeleted && (
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-600 ring-1 ring-inset ring-slate-300"> <span className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-600 ring-1 ring-inset ring-slate-300">
Đã xóa chỉ xem Đã xóa chỉ xem
</span> </span>
)} )}
{!showDeleted && phaseFilter === 9 && ( {!showDeleted && phase === 9 && (
<span className="rounded-full bg-teal-50 px-2.5 py-1 text-[11px] font-semibold text-teal-700 ring-1 ring-inset ring-teal-200"> <span className="rounded-full bg-teal-50 px-2.5 py-1 text-[11px] font-semibold text-teal-700 ring-1 ring-inset ring-teal-200">
Đã phát hành Đã phát hành
</span> </span>
@ -216,61 +153,36 @@ export function MyContractsPage() {
</span> </span>
)} )}
{!showDeleted && ( {!showDeleted && (
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}> <Button onClick={() => navigate(type ? `/contracts/new?type=${type}` : '/contracts/new')}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tạo mới Tạo mới
</Button> </Button>
)} )}
</header> </header>
{/* 3-panel grid — flex-1 để fill phần còn lại của viewport. {/* 3-panel grid — GIỮ đúng số đo bản W2 (= số đo host KHKK `KhkkListPage.tsx:171`). */}
[S190 W2] 320px → 400px cho cột cây (khuôn host KHKK `KhkkListPage.tsx:171`). */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]"> <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1 — CÂY TOÀN TRÌNH (đến từ Panel 3 cũ) + Select loại HĐ. {/* Panel 1 — cây toàn trình ⟂ danh sách phẳng, xem ContractListPanel.tsx */}
`hidden … lg:flex` = GIỮ NGUYÊN diện hiển thị cũ của cây (nó vốn ở panel <ContractListPanel
`hidden lg:block`) ⇒ < lg không đổi gì, và bẫy "`hideHeader` nuốt nút Hiện/Ẩn cây selectedId={selectedId}
ở mobile" KHÔNG chạm host này vì nút đó chỉ sống ở < lg. */} onSelect={selectContract}
<aside className="hidden flex-col overflow-hidden border-r border-slate-200 bg-white lg:flex"> search={search}
<div className="border-b border-slate-200 p-3"> onSearchChange={updateSearch}
<Select type={type}
value={treeType ?? ''} onTypeChange={pickType}
aria-label="Lọc loại HĐ trên cây toàn trình" phase={phase}
onChange={e => setTreeTypeOverride({ onPhaseChange={pickPhase}
forType: typeFilter, pendingMe={pendingMe}
value: e.target.value === '' ? null : Number(e.target.value), deletedView={showDeleted}
})}
>
{/* Ca URL trần (`/my-contracts` không `?type=`) phải có lựa chọn hợp lệ. */}
<option value="">Tất cả loại </option>
{Object.entries(ContractTypeLabel).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
</div>
{/* [X-3 / SF-26 — bài S168 F-1] Cột này TỰ CUỘN ⇒ thân cây PHẢI bỏ trần `max-h` mặc
định của panel, nếu không là 2 thanh cuộn lồng nhau. */}
<div className="flex-1 overflow-y-auto">
<PipelineTreePanel
currentStage={3}
hideHeader
hideSearch
bodyMaxHClass="lg:max-h-none"
stage2Options={{
// Bấm leaf HĐ ⇒ chọn vào Panel 2 qua `?id=`; URL Ở LẠI `/my-contracts`.
onOpenContract: selectContract,
// `null` ⇒ `undefined` ⇒ hook coi như KHÔNG lọc (đủ mọi loại).
stage3TypeFilter: treeType ?? undefined,
}}
/> />
</div>
</aside>
{/* Panel 2 — Detail content + quy trình/lịch sử duyệt */} {/* Panel 2 — chi tiết HĐ. EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy; thiếu nó
thì host 3-panel mở ra là một mảng trắng ở giữa. */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && ( {!selectedId && (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
title="Chọn 1 HĐ ở cây bên trái hoặc danh sách bên phải" title="Chọn 1 HĐ ở cột bên trái"
description="Chi tiết HĐ + thông tin / góp ý / file đính kèm sẽ hiển thị ở đây." description="Chi tiết HĐ + thông tin / góp ý / file đính kèm sẽ hiển thị ở đây."
/> />
)} )}
@ -278,89 +190,36 @@ export function MyContractsPage() {
<div className="text-sm text-slate-500">Đang tải </div> <div className="text-sm text-slate-500">Đang tải </div>
)} )}
{selectedId && detail.data && ( {selectedId && detail.data && (
<div className="space-y-6">
<ContractDetailContent contract={detail.data} onBack={clearSelection} /> <ContractDetailContent contract={detail.data} onBack={clearSelection} />
{/* [S190 W2] Khối "workflow + lịch sử duyệt" TRƯỚC ở Panel 3; Panel 3 nay là
danh-sách nên nó về đứng ngay dưới detail của chính HĐ đó — KHÔNG xoá. */}
<WorkflowHistoryPanel contract={detail.data} />
</div>
)} )}
</main> </main>
{/* Panel 3 — DANH SÁCH HĐ (đến từ Panel 1 cũ, nguyên chức năng: search + empty-state + {/* Panel 3 — quy trình duyệt + lịch sử (khuôn KHKK QĐ-2 `KhkkListPage.tsx:252-277`).
các lọc `?type=` / `?phase=9` / `?deleted=1` hiện có). Hiện ở MỌI bề rộng y như 🔴 Chưa chọn ⇒ CHỈ hộp dashed, KHÔNG nhét `PipelineTreePanel` vào đây: cây đã sống
trước hoán-đổi ⇒ mobile không mất gì. */} ở panel 1: nhét lần hai là kể một thứ hai lần (đúng lý do QĐ-2 của KHKK). Bản S168
<aside className="flex flex-col overflow-hidden border-l border-slate-200 bg-white"> từng để cây ở ô này — nay ô đó đã có chủ mới. */}
<div className="border-b border-slate-200 p-3"> <aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
<div className="relative"> {!selectedId && (
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" /> <div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">
<Input <X className="mx-auto mb-2 h-5 w-5" />
value={pendingMe ? '' : search} {showDeleted
onChange={e => updateSearch(e.target.value)} ? 'HĐ đã xóa không còn quy trình duyệt.'
disabled={pendingMe} : 'Chọn 1 HĐ ở cột bên trái để xem quy trình duyệt.'}
placeholder="Tìm theo mã / tên / NCC…"
className="pl-8"
/>
</div>
{/* [S190 W3] KHOÁ ô tìm ở chế độ "Đang duyệt" — và nói VÌ SAO bằng CHỮ HIỆN,
KHÔNG bằng `title` (tooltip trên control `disabled` bị Chromium nuốt — bài S182).
Trang cũng KHÔNG có UI phân trang (pageSize 100 cố định) nên không có gì để ẩn;
inbox trả trọn gói, không phân trang — `[carry:w7b-inbox-param]`. */}
{pendingMe && (
<p className="mt-1.5 text-[11px] leading-snug text-slate-500">
Chế đ Đang duyệt đc hộp thư đến chưa hỗ trợ tìm kiếm phía máy chủ.
</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div> </div>
)} )}
{!list.isLoading && rows.length === 0 && ( {selectedId && detail.data && (
<div className="p-6"> <div className="space-y-4">
<EmptyState {/* [WQ3b S190 · YC-030 Q5] Máy duyệt V2 đứng ĐẦU panel-3 — đúng vị trí khuôn
icon={FileText} KHKK (`KhkkListPage` panel-3); trước đây nó nằm TRONG ContractDetailContent
title={emptyTitle} (panel-2) — đã dời ra host để không nhân đôi bộ nút (FLAG WQ1). */}
description={emptyDescription} {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> </div>
)} )}
<ul className="divide-y divide-slate-100">
{rows.map(c => (
<li key={c.id}>
<button
onClick={() => selectContract(c.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">
{c.tenHopDong ?? '(chưa đặt tên)'}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{fmtMoney(c.giaTri)}</span>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
</li>
))}
</ul>
</div>
</aside> </aside>
</div> </div>

View File

@ -188,6 +188,41 @@ export type ContractDetail = {
currentWorkflowStepIndex?: number | null currentWorkflowStepIndex?: number | null
/** Cây quy trình THÔ (xem `ContractWorkflowStep`). */ /** Cây quy trình THÔ (xem `ContractWorkflowStep`). */
workflowSteps?: ContractWorkflowStep[] | null 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 // [W2 S187 · YC-023] Khối "Nguồn gốc KHKK" — BE reverse-join `ContractSigningPlanLines