diff --git a/fe-admin/src/components/contracts/ContractDetailContent.tsx b/fe-admin/src/components/contracts/ContractDetailContent.tsx index ecb514a..db743cc 100644 --- a/fe-admin/src/components/contracts/ContractDetailContent.tsx +++ b/fe-admin/src/components/contracts/ContractDetailContent.tsx @@ -12,9 +12,8 @@ import { PhaseBadge } from '@/components/PhaseBadge' import { SlaTimer } from '@/components/SlaTimer' import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection' import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' -// [W6 S187] Màn duyệt V2-trạm — CHỈ dùng cho HĐ đã pin `approvalWorkflowId` (xem khối -// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy. -import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel' +// [WQ3b S190 · YC-030 Q5] 🧊 Import `ContractWorkflowPanel` (W6/S187) ĐÃ GỠ khỏi file này — +// máy duyệt dời sang PANEL-3 của từng host, xem khối lineage ở `:isV2`. import { Button } from '@/components/ui/Button' import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' @@ -133,13 +132,23 @@ export function ContractDetailContent({ // duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`) // VÀ cùng cờ mục "Ý kiến cấp duyệt (Quy trình V2)" bên dưới đã dùng từ S29. Một cờ, ba nơi // đọc, không đẻ khái niệm "HĐ mới" thứ hai. - // • V2 ⇒ `ContractWorkflowPanel` (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại, - // có gating theo Cấp đang chờ). + // • V2 ⇒ máy duyệt V2-trạm (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại, có + // gating theo Cấp đang chờ) — 🔴 [WQ3b S190 · YC-030 Q5] KHÔNG CÒN render trong file này. // • V1 legacy (không pin — 7 HĐ prod chạy nhánh này, `ContractWorkflowService.cs:110`) // ⇒ GIỮ NGUYÊN 2 nút "Yêu cầu sửa"/"Duyệt → tiếp" ở header + Dialog chọn phase. // 2 khối là THAY THẾ nhau, không chồng: để cả hai thì cùng một HĐ V2 có 2 đường bấm duyệt // (đường header không nói được đang ở Bước/Cấp nào, cũng không gating theo người) — mâu - // thuẫn hiển thị chứ không phải tiện thêm. + // thuẫn hiển thị chứ không phải tiện thêm. `isV2` Ở LẠI vì nửa V1 (`!isV2` ở header) vẫn đọc. + // + // ===== [WQ3b S190 · YC-030 Q5] MÁY DUYỆT DỜI VỀ PANEL-3 (khuôn KHKK) ==================== + // 🧊 W6/S187 đặt `ContractWorkflowPanel` ngay TRONG file này (nhánh `isV2`, chỗ khối JSX đã + // gỡ bên dưới) = panel 2 của các host 3-panel. FLAG WQ1 (`MyContractsPage.tsx:18-23`) chỉ ra + // hệ quả: panel 3 muốn mang máy duyệt như `KhkkListPage` thì MỘT HĐ có HAI bộ nút Duyệt/Trả + // lại — đúng thứ chính khối trên cấm. Owner chốt YC-030 Q5 "cấu trúc duyệt như KHKK" ⇒ máy + // duyệt ra khỏi thân chi tiết, mỗi HOST tự render ở panel cạnh (`MyContractsPage` · + // `ContractDetailPage` ×2 · `ContractsListPage` fe-admin · `InboxPage`), CÙNG điều kiện + // `approvalWorkflowId != null` đang tính ở dưới. Host nào quên render thì HĐ V2 ở host đó + // KHÔNG có đường duyệt — đây là chỗ dễ hụt nhất của lượt dời này, đã liệt đủ 5 host. const isV2 = c.approvalWorkflowId != null // ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) ===================================== @@ -187,8 +196,9 @@ export function ContractDetailContent({ - {/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở - `ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */} + {/* [W6 S187 · sửa WQ3b S190] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 + thao tác duyệt ở máy duyệt V2-trạm tại PANEL-3 của host (đúng Bước/Cấp + đúng + người), không ở header này. */} {!isV2 && availableTargets.length > 0 && (
diff --git a/fe-admin/src/types/contracts.ts b/fe-admin/src/types/contracts.ts index 66b2bd5..cc4d80a 100644 --- a/fe-admin/src/types/contracts.ts +++ b/fe-admin/src/types/contracts.ts @@ -184,6 +184,41 @@ export type ContractDetail = { currentWorkflowStepIndex?: number | null /** Cây quy trình THÔ (xem `ContractWorkflowStep`). */ workflowSteps?: ContractWorkflowStep[] | null + + // ===== [WQ3b S190 · YC-030 Q3] 3 field meta/kết-thúc-sớm (BE `ContractDtos.cs:103-105`) ===== + // Khai `?` theo ĐÚNG quy ước 2-nghĩa của khối trên: `undefined` = bundle/cache CHƯA có WQ3-BE. + /** + * Mã QUY TRÌNH V2 đã pin (`HD-TP`, `HD-NCC`…) — 🔴 KHÔNG phải `maHopDong`, hai thứ cùng + * gọi là "mã". `null` khi HĐ chưa pin V2 (V1 legacy) ⇒ panel không vẽ dòng meta. + */ + workflowCode?: string | null + /** Số phiên bản quy trình đã pin — hiển thị `v{NN}` (pad 2). */ + workflowVersion?: number | null + /** + * CỜ RUNTIME (BE Mig 73, write-site duy nhất `ContractWorkflowService.cs:529`): HĐ này ĐÃ + * kết thúc sớm tại một Cấp được uỷ quyền. 🔴 KHÔNG suy từ `allowApproverFinalize` của cây — + * cờ cây là CẤU HÌNH (được phép), cờ này là VIỆC ĐÃ XẢY RA (bài #78 / PE Mig 60). + * BE non-nullable (V1 legacy = `false`); `?` ở đây chỉ để chịu payload cũ. + */ + endedByLevelFinalize?: boolean +} + +/** + * [WQ3b S190 · YC-030 Q3] Body `POST /contracts/{id}/transitions` — mirror BE + * `TransitionContractBody` (`ContractsController.cs:269-274`). + * 🔴 2 field cuối OMIT-ĐƯỢC và omit KHÔNG đồng nghĩa `false`: + * • `applyLevelFinalize` bỏ trống ⇒ controller `?? true` (`:71`) ⇒ Cấp có + * `allowApproverFinalize` sẽ KẾT THÚC quy trình. Muốn opt-out phải gửi `false` TƯỜNG MINH. + * • `skipToFinal` bỏ trống ⇒ `false` (mặc định record BE). FE hiện KHÔNG có đường bấm cho cờ + * này (nó là F2/Mig 31, cần `allowApproverSkipToFinal` mà DTO cây chưa trả) — khai ở đây để + * nơi gọi thấy đủ hình dạng API, KHÔNG phải để dùng ngay. + */ +export type TransitionContractBody = { + targetPhase: number + decision: number + comment: string | null + applyLevelFinalize?: boolean + skipToFinal?: boolean } // [W2 S187 · YC-023] Khối "Nguồn gốc KHKK" — BE reverse-join `ContractSigningPlanLines diff --git a/fe-user/src/components/contracts/ContractDetailContent.tsx b/fe-user/src/components/contracts/ContractDetailContent.tsx index ecb514a..db743cc 100644 --- a/fe-user/src/components/contracts/ContractDetailContent.tsx +++ b/fe-user/src/components/contracts/ContractDetailContent.tsx @@ -12,9 +12,8 @@ import { PhaseBadge } from '@/components/PhaseBadge' import { SlaTimer } from '@/components/SlaTimer' import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection' import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab' -// [W6 S187] Màn duyệt V2-trạm — CHỈ dùng cho HĐ đã pin `approvalWorkflowId` (xem khối -// dual-render ở dưới). HĐ V1 legacy không import nhánh này lúc chạy. -import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel' +// [WQ3b S190 · YC-030 Q5] 🧊 Import `ContractWorkflowPanel` (W6/S187) ĐÃ GỠ khỏi file này — +// máy duyệt dời sang PANEL-3 của từng host, xem khối lineage ở `:isV2`. import { Button } from '@/components/ui/Button' import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' @@ -133,13 +132,23 @@ export function ContractDetailContent({ // duyệt (`ContractWorkflowService.cs:111` `if (contract.ApprovalWorkflowId is Guid awId)`) // VÀ cùng cờ mục "Ý kiến cấp duyệt (Quy trình V2)" bên dưới đã dùng từ S29. Một cờ, ba nơi // đọc, không đẻ khái niệm "HĐ mới" thứ hai. - // • V2 ⇒ `ContractWorkflowPanel` (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại, - // có gating theo Cấp đang chờ). + // • V2 ⇒ máy duyệt V2-trạm (sơ đồ Bước→Cấp + banner lượt-ai + 2 nút Duyệt/Trả lại, có + // gating theo Cấp đang chờ) — 🔴 [WQ3b S190 · YC-030 Q5] KHÔNG CÒN render trong file này. // • V1 legacy (không pin — 7 HĐ prod chạy nhánh này, `ContractWorkflowService.cs:110`) // ⇒ GIỮ NGUYÊN 2 nút "Yêu cầu sửa"/"Duyệt → tiếp" ở header + Dialog chọn phase. // 2 khối là THAY THẾ nhau, không chồng: để cả hai thì cùng một HĐ V2 có 2 đường bấm duyệt // (đường header không nói được đang ở Bước/Cấp nào, cũng không gating theo người) — mâu - // thuẫn hiển thị chứ không phải tiện thêm. + // thuẫn hiển thị chứ không phải tiện thêm. `isV2` Ở LẠI vì nửa V1 (`!isV2` ở header) vẫn đọc. + // + // ===== [WQ3b S190 · YC-030 Q5] MÁY DUYỆT DỜI VỀ PANEL-3 (khuôn KHKK) ==================== + // 🧊 W6/S187 đặt `ContractWorkflowPanel` ngay TRONG file này (nhánh `isV2`, chỗ khối JSX đã + // gỡ bên dưới) = panel 2 của các host 3-panel. FLAG WQ1 (`MyContractsPage.tsx:18-23`) chỉ ra + // hệ quả: panel 3 muốn mang máy duyệt như `KhkkListPage` thì MỘT HĐ có HAI bộ nút Duyệt/Trả + // lại — đúng thứ chính khối trên cấm. Owner chốt YC-030 Q5 "cấu trúc duyệt như KHKK" ⇒ máy + // duyệt ra khỏi thân chi tiết, mỗi HOST tự render ở panel cạnh (`MyContractsPage` · + // `ContractDetailPage` ×2 · `ContractsListPage` fe-admin · `InboxPage`), CÙNG điều kiện + // `approvalWorkflowId != null` đang tính ở dưới. Host nào quên render thì HĐ V2 ở host đó + // KHÔNG có đường duyệt — đây là chỗ dễ hụt nhất của lượt dời này, đã liệt đủ 5 host. const isV2 = c.approvalWorkflowId != null // ===== [W5 S187 · YC-023 GĐ3] NGUỒN GỐC KHKK (GĐ2) ===================================== @@ -187,8 +196,9 @@ export function ContractDetailContent({ - {/* [W6 S187] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 thao tác duyệt ở - `ContractWorkflowPanel` bên dưới (đúng Bước/Cấp + đúng người), không ở đây. */} + {/* [W6 S187 · sửa WQ3b S190] `!isV2` = nửa V1 của dual-render. HĐ pin quy trình V2 + thao tác duyệt ở máy duyệt V2-trạm tại PANEL-3 của host (đúng Bước/Cấp + đúng + người), không ở header này. */} {!isV2 && availableTargets.length > 0 && (
+ ) +} + +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` (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('/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>( + 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 ( + + ) +} diff --git a/fe-user/src/components/contracts/ContractWorkflowPanel.tsx b/fe-user/src/components/contracts/ContractWorkflowPanel.tsx index 48b7239..5ca5be9 100644 --- a/fe-user/src/components/contracts/ContractWorkflowPanel.tsx +++ b/fe-user/src/components/contracts/ContractWorkflowPanel.tsx @@ -23,20 +23,26 @@ // phân 2 ngả bằng ĐÚNG `targetPhase`: `== TuChoi` ⇒ TuChoi, else ⇒ TraLai. Gửi 98 tường // minh thay vì "số nào cũng được miễn khác 99". // -// ══ 3 MẢNH CỦA KHUÔN KHKK CỐ Ý KHÔNG PORT (đo ra, không phải cắt cho gọn) ═════════════ -// 1. ❌ Ô-tích "Cấp này KẾT THÚC quy trình": `TransitionContractBody` có ĐÚNG 3 field; -// `skipToFinal` là tham số C# default (`TransitionAsync(..., bool skipToFinal=false)`) -// KHÔNG nằm trên API surface ⇒ vẽ ô-tích = control chết, tích xong không đổi gì. -// 2. ❌ Badge "⚑ Duyệt thay CEO" cho Cấp có `allowApproverFinalize`: `ApproveV2Async` của HĐ -// KHÔNG ĐỌC cờ đó lần nào (`ContractWorkflowService.cs:230-407` — cờ duy nhất nó đọc là -// `AllowApproverSkipToFinal` `:335`, cho đường skip mà FE không gọi được). DTO vẫn trả -// field (`ContractDtos.cs:138`) và chú thích W2 ghi rõ "dùng hay không là quyết của W6". -// Vẽ badge nói "duyệt ở đây là KẾT THÚC" = NÓI DỐI: duyệt xong vẫn chạy tiếp Cấp sau. -// 3. ❌ Banner kết-thúc-sớm + meta `{code} v{NN} · {name}`: `ContractDetail` 0 field -// `endedByLevelFinalize`/`workflowCode`/`workflowVersion`/`workflowName`. Mượn -// `c.workflow.policyName` là ghép nhãn của workflow-policy V1 vào quy trình V2 — hai -// vật khác nhau. Cây thô đã INLINE trong detail ⇒ cũng KHÔNG gọi thêm -// `/approval-workflows-v2/{id}` ("KHÔNG đẻ đường thứ 3" — `ContractDtos.cs:86-88`). +// ══ [WQ3b S190 · YC-030 Q3] 3 MẢNH KHUÔN KHKK — TỪ CHỐI @S187, PORT ĐỦ ĐỢT NÀY ════════ +// 🧊 Bản S187 liệt 3 mảnh này là "CỐ Ý KHÔNG PORT" và lý do khi đó ĐÚNG: cả ba đều thiếu +// máy ở BE. WQ3-BE (cùng phiên S190) dựng xong máy ⇒ 3 lý do đó HẾT HIỆU LỰC, không phải +// bị bỏ qua. Ghi lại lineage để người sau đọc panel này không tưởng ai đó lờ mất chú thích: +// 1. ✅ Ô-tích "Cấp này KẾT THÚC quy trình" — `TransitionContractBody` nay có +// `ApplyLevelFinalize`/`SkipToFinal` (`ContractsController.cs:269-274`), `?? true` ở +// `:71`. Trước đây vẽ ô-tích là control CHẾT; nay tích/bỏ-tích đổi thật đường đi. +// 🔴 Ô-tích là OPT-OUT: BE cần CẢ HAI vế `AllowApproverFinalize ∧ applyLevelFinalize` +// (`ContractWorkflowService.cs:527`), mà bỏ trống ⇒ `true`. Vì vậy panel gửi giá trị +// TƯỜNG MINH mỗi lần duyệt, không dựa vào mặc định của server. +// 2. ✅ Badge cho Cấp có `allowApproverFinalize` — `ApproveV2Async` NAY ĐỌC cờ đó (`:527`) +// ⇒ câu "duyệt ở đây là KẾT THÚC" không còn nói dối. CHỮ trên badge KHÔNG chép nguyên +// KHKK ("⚑ Duyệt thay CEO"): quy trình HĐ không có trạm CEO nào để "thay" — terminal +// của HĐ là `DaPhatHanh` + gen mã RG-001 (`:392`). Dùng chữ đúng vật: "⚑ Cấp kết thúc". +// 3. ✅ Banner kết-thúc-sớm + meta `{code} v{NN}` — `ContractDetail` nay có +// `workflowCode`/`workflowVersion`/`endedByLevelFinalize` (`ContractDtos.cs:103-105`), +// không phải mượn `c.workflow.policyName` của V1 nữa. ⚠️ VẪN THIẾU so với KHKK: +// `workflowName` + `finalizeStepName`/`finalizeLevelName` — BE HĐ không trả 3 field đó +// ⇒ meta chỉ `{code} v{NN}`, còn tên Bước/Cấp kết-thúc panel TỰ SUY từ ý-kiến ký gần +// nhất (xem chỗ dựng `finalizeMark`), KHÔNG bịa field và KHÔNG gọi thêm endpoint thứ 3. // // ══ 2 KHỐI BỎ VÌ TRÙNG CHỖ ĐÃ CÓ TRÊN CÙNG TRANG ════════════════════════════════════ // Cả 4 host (`ContractDetailPage` ×2 app · `MyContractsPage` · `ContractsListPage` · @@ -62,6 +68,7 @@ import { type ContractDetail, type ContractLevelOpinion, type ContractWorkflowLevel, + type TransitionContractBody, } from '@/types/contracts' type NodeStatus = 'Done' | 'Current' | 'Pending' @@ -102,6 +109,10 @@ export function ContractWorkflowPanel({ const { user } = useAuth() const [action, setAction] = useState(null) const [comment, setComment] = useState('') + // [WQ3b S190] Mặc định KHÔNG tick — khuôn KHKK (`KhkkWorkflowPanel.tsx:103`, vốn theo + // `PeWorkflowPanel.tsx:61`). Người duyệt phải chủ động chọn "dừng ở đây"; mặc-định-tick là + // đổi hành vi im lặng của mọi Cấp được uỷ quyền. + const [applyLevelFinalize, setApplyLevelFinalize] = useState(false) const steps = c.workflowSteps ?? [] const curStepIdx = c.currentWorkflowStepIndex @@ -154,12 +165,43 @@ export function ContractWorkflowPanel({ const actorInLevel = isAdmin || actorIsCurrentApprover const blockedByLevel = isWaiting && !actorInLevel + // [WQ3b S190] Ô-tích phải soi cờ của ĐÚNG Cấp mà BE sẽ chấm, không phải "có Cấp nào trong + // nhóm bật cờ". Mirror `matchingLevel` (`ContractWorkflowService.cs:442-443`): own-level + // TRƯỚC, Admin không có own ⇒ phần tử ĐẦU nhóm cùng-order. Soi cờ của Cấp khác là hiện một + // ô-tích nói dối — tích xong BE vẫn trình tiếp vì cờ ở Cấp bị chấm là `false`. + const actingLevel = currentLevels.find(l => l.approverUserId === user?.id) + ?? (isAdmin ? currentLevels[0] : undefined) + const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true + + // [WQ3b S190] Tên Bước/Cấp đã KẾT THÚC sớm — DTO HĐ không có `finalizeStepName`/ + // `finalizeLevelName` như KHKK (`ContractDtos.cs:103-105` chỉ 3 field). Suy từ ý-kiến ký + // GẦN NHẤT: `ApproveV2Async` UPSERT opinion NGAY TRƯỚC nhánh finalize trong cùng giao dịch + // (`:435-529`) ⇒ chữ ký cuối cùng chính là Cấp đã bấm kết thúc. Không suy được (0 ý kiến, + // phiếu cũ) ⇒ để `null` và banner nói câu chung, KHÔNG bịa tên. + const finalizeMark = useMemo(() => { + const ops = c.levelOpinions ?? [] + if (ops.length === 0) return null + const last = [...ops].sort((a, b) => Date.parse(b.signedAt) - Date.parse(a.signedAt))[0] + return { stepOrder: last.stepOrder, stepName: last.stepName, levelOrder: last.levelOrder, levelName: last.levelName } + }, [c.levelOpinions]) + const transition = useMutation({ mutationFn: async (a: PanelAction) => { // Xem khối WIRE ở đầu file: cặp (targetPhase, decision) LÀ thứ chọn nhánh trong // `TransitionAsync`, không phải một tên endpoint riêng. - const body = a === 'approve' - ? { targetPhase: ContractPhase.ChoDuyet, decision: ApprovalDecision.Approve, comment: comment.trim() || null } + // [WQ3b S190 · YC-030 Q3] `applyLevelFinalize` CHỈ gắn ở nhánh duyệt, và GẮN TƯỜNG MINH + // kể cả khi Cấp không được uỷ quyền: bỏ trống ⇒ controller `?? true` + // (`ContractsController.cs:71`), tức im lặng = ĐỒNG Ý kết thúc sớm. Gửi + // `approverFinalizeEligible ? applyLevelFinalize : true` = đúng khuôn KHKK + // (`KhkkWorkflowPanel.tsx:199`): Cấp không bật cờ thì vế BE `AllowApproverFinalize` + // đã `false` nên giá trị này vô hại; Cấp có bật thì đi theo đúng ô-tích người dùng chọn. + const body: TransitionContractBody = a === 'approve' + ? { + targetPhase: ContractPhase.ChoDuyet, + decision: ApprovalDecision.Approve, + comment: comment.trim() || null, + applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true, + } : { targetPhase: ContractPhase.TraLai, decision: ApprovalDecision.Reject, comment: comment.trim() || null } await api.post(`/contracts/${c.id}/transitions`, body) }, @@ -167,6 +209,10 @@ export function ContractWorkflowPanel({ toast.success(a === 'approve' ? 'Đã duyệt cấp hiện tại.' : 'Đã trả lại người soạn sửa.') setAction(null) setComment('') + // Trả ô-tích về mặc định. (Khuôn KHKK `:215-220` KHÔNG reset cờ này — với HĐ thì tick còn + // sót là nguy: duyệt Cấp 1 có tick nhưng cấp đó không được uỷ quyền ⇒ BE bỏ qua, sang Cấp 2 + // ĐƯỢC uỷ quyền thì hộp thoại mở ra đã sẵn tick, kết thúc sớm mà người bấm không chủ ý.) + setApplyLevelFinalize(false) // `['contract', id]` = queryKey detail của CẢ 4 host (`ContractDetailPage` ×2 app, // `MyContractsPage`, `ContractsListPage`, `InboxPage`). 3 key còn lại là prefix của // các danh sách — TanStack khớp theo tiền tố nên không cần liệt đủ tham số lọc. @@ -192,15 +238,42 @@ export function ContractWorkflowPanel({ return (
- {/* ── 1. Header ───────────────────────────────────────────────────────── */} + {/* ── 1. Header + meta {code} v{NN} ───────────────────────────────────── */}

Quy trình duyệt

+ {/* [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 && ( +

+ {c.workflowCode} + {c.workflowVersion != null && ` v${String(c.workflowVersion).padStart(2, '0')}`} +

+ )}

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 là sang Cấp kế.

+ {/* ── 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 && ( +
+ ✅ 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ờ vì không ai phải duyệt. +
+ )} + {/* ── 2. Sơ đồ Bước → Cấp → NV ────────────────────────────────────────── */} {steps.length > 0 ? (
    @@ -225,9 +298,17 @@ export function ContractWorkflowPanel({
      {orders.map(order => { const group = step.levels.filter(l => l.order === order) - const ls: NodeStatus = levelStatus(idx, order) + // [WQ3b S190] Cấp được uỷ quyền kết thúc — cờ CẤU HÌNH (khác cờ runtime + // `endedByLevelFinalize` ở banner trên). + const finalizeHere = group.some(l => l.allowApproverFinalize) + const hasSigned = group.some(l => signedByLevelId.has(l.id)) + // Nhánh KHÔNG CHẠY: HĐ đã phát hành bằng đường kết-thúc-sớm ⇒ các Cấp sau + // đó không ai ký. Vẽ MỜ + giữ trạng thái ○ thay vì bịa ✓ cho cả cây + // (`stepStatus` trả 'Done' cho MỌI Bước khi `isIssued`). + const skipped = isIssued && c.endedByLevelFinalize === true && !hasSigned + const ls: NodeStatus = skipped ? 'Pending' : levelStatus(idx, order) return ( -
    • +
    • {STATUS_ICON[ls]} @@ -237,6 +318,21 @@ export function ContractWorkflowPanel({ {group[0]?.name || `Cấp ${order}`} {ls === 'Current' && đang chờ} {ls === 'Done' && đã duyệt} + {skipped && không phải qua} + {/* 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 `` 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 && ( + + ⚑ Cấp kết thúc + + )}
      {/* Liệt-kê NV của cấp: người ĐÃ KÝ in đậm, NV cùng cấp còn lại mờ. */}
      @@ -369,6 +465,30 @@ export function ContractWorkflowPanel({ từ Bước 1 · Cấp 1.
      )} + {/* [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 && ( +
      + +
      + )} diff --git a/fe-user/src/pages/InboxPage.tsx b/fe-user/src/pages/InboxPage.tsx index 1a20cd4..7fd33e4 100644 --- a/fe-user/src/pages/InboxPage.tsx +++ b/fe-user/src/pages/InboxPage.tsx @@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' import { Inbox, AlertTriangle, Clock, FileText, Search, ClipboardList } from 'lucide-react' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' +import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { PhaseBadge } from '@/components/PhaseBadge' 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.
)} - {selectedId && detail.data && } + {selectedId && detail.data && ( +
+ {/* [WQ3b S190 · YC-030 Q5] Máy duyệt V2 dời từ ContractDetailContent ra host. */} + {detail.data.approvalWorkflowId != null && ( +
+ +
+ )} + +
+ )}
diff --git a/fe-user/src/pages/contracts/ContractDetailPage.tsx b/fe-user/src/pages/contracts/ContractDetailPage.tsx index 77e4e88..2de1196 100644 --- a/fe-user/src/pages/contracts/ContractDetailPage.tsx +++ b/fe-user/src/pages/contracts/ContractDetailPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' +import { ContractWorkflowPanel } from '@/components/contracts/ContractWorkflowPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { api } from '@/lib/api' import type { ContractDetail } from '@/types/contracts' @@ -27,7 +28,14 @@ export function ContractDetailPage() {
navigate(-1)} />
-