[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:
@ -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, có
|
||||||
// có 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 tận 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">
|
||||||
|
|||||||
@ -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 là sang Cấp kế.
|
người duyệt là 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ờ vì 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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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, có
|
||||||
// có 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 tận 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">
|
||||||
|
|||||||
346
fe-user/src/components/contracts/ContractListPanel.tsx
Normal file
346
fe-user/src/components/contracts/ContractListPanel.tsx
Normal 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 HĐ</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>HĐ đã 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 — HĐ 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 gõ ô 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 có quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng được — danh
|
||||||
|
sách HĐ vẫn liệt kê đầ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 HĐ.</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} HĐ` : `${rows.length} HĐ`}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 là sang Cấp kế.
|
người duyệt là 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ờ vì 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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
HĐ của tôi {typeLabel && <span className="text-slate-500">· {typeLabel}</span>}
|
HĐ 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 HĐ mới
|
Tạo HĐ 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 HĐ</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 HĐ…</div>
|
<div className="text-sm text-slate-500">Đang tải HĐ…</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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user