// [S174 owner] PANEL 1 (danh sách) của màn "Kế hoạch ký kết HĐ — Thao tác" — 1 cột, 2 TẦNG: // · tầng TRÊN = phiếu Duyệt NCC ĐÃ DUYỆT (có NCC trúng thầu) mà CHƯA lập kế hoạch ký kết, // gom theo DỰ ÁN — "việc còn phải làm"; bấm = nạp thẳng vào form tạo; // · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — "việc đã làm rồi"; bấm = XEM tại chỗ (read-only). // File MIRROR SHA256 identical với fe-user counterpart. // // 🔴 LỊCH SỬ ĐẶT SAI CHỖ — ghi để không tái phạm: bản đầu S174 gắn panel này thành cột 3 của // màn *Duyệt NCC — Thao tác* (`PurchaseEvaluationWorkspacePage`). Anh bác: **"chỗ duyệt NCC // đã golive và đã tốt rồi"** — ý anh từ đầu là làm màn Thao tác của KHKK **GIỐNG GIAO DIỆN** // Thao tác của Duyệt NCC, chứ không phải chèn thêm thứ vào màn Duyệt NCC. Đã revert // `PurchaseEvaluationWorkspacePage` về đúng byte của `1b3bcaa`. Bài: "làm giống màn X" ≠ // "sửa màn X". // // 🔴 0 ENDPOINT MỚI — dùng lại đúng 2 cửa đã sống: // · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage` // đang gọi) → `KhkkPeCandidateDto[]`; "chưa có kế hoạch" = `existingGroups` RỖNG. // 🔸 Từ K2 S164 cửa này KHÔNG còn loại PE đã có kế hoạch (nó trả cả PE đã dùng vài // nhóm, kèm `existingGroups`) ⇒ lọc rỗng là việc của FE, đừng tưởng BE lọc hộ. // · `GET /contract-signing-plans` → `PagedResult`. // // 🔴 GATE QUYỀN (gotcha #85 — mặt đã cắn thật ở S162): màn đang đứng là *Duyệt NCC*, nhưng // CẢ HAI cửa trên gác bằng policy `KeHoachKyKet.Read` (`ContractSigningPlansController.cs:36` // + `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa cha, KHÔNG OR key // con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key // của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì". import { useQuery } from '@tanstack/react-query' import { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react' import { useState } from 'react' import { usePermission } from '@/hooks/usePermission' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KhkkPhase, type KhkkListItemDto, type KhkkPeCandidateDto, type PagedResult, } from '@/types/khkk' const KHKK_TIER_PAGE_SIZE = 30 function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' }) } /** Vỏ 1 tầng — `min-h-0 flex-1` ở CẢ HAI tầng là thứ tạo ra tỉ lệ 50/50 thật; thiếu * `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */ function Tier({ title, count, icon, accent, children, }: { title: string count: number | null icon: React.ReactNode accent: string children: React.ReactNode }) { return (
{icon} {title} {count !== null && ( {count} )}
{children}
) } export function KhkkWorkListPanel({ onPickPe, onPickPlan, selectedPeId, selectedPlanId, onEditPlan, group, }: { /** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */ onPickPe: (peId: string) => void /** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. Anh chốt: "click vào phiếu * nào thì cho hiện phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc". */ onPickPlan: (planId: string) => void selectedPeId?: string | null selectedPlanId?: string | null /** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒ * không vẽ bút chì (cột giữ đúng hình cũ). */ onEditPlan?: (planId: string) => void /** [S175 - anh: "Loc theo quy trinh luon"] Nhom duyet cua leaf menu dang dung. * Tang duoi CHI hien ke hoach thuoc dung nhom nay - dung o leaf N2 ma thay phieu * N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */ group?: number | null }) { // [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP. // Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau // này tự mở, không bị ẩn im lặng vì chưa ai bấm mở bao giờ. const [collapsed, setCollapsed] = useState>(new Set()) const { can } = usePermission() // 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). const canKhkk = can('KeHoachKyKet', 'Read') const candidates = useQuery({ // Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì // cột tự tươi, không phải nhớ invalidate thêm một key thứ hai. queryKey: ['khkk-pe-candidates'], queryFn: async () => (await api.get('/contract-signing-plans/approved-pe-awaiting-plan')).data, enabled: canKhkk, }) const plans = useQuery({ queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }], queryFn: async () => (await api.get>('/contract-signing-plans', { params: { page: 1, pageSize: KHKK_TIER_PAGE_SIZE }, })).data, enabled: canKhkk, }) // "Chưa được tạo phiếu KHKK" = chưa dùng nhóm duyệt NÀO. `?? []` phòng payload cũ còn // trong cache React Query (DTO trước K2 không có field này) — thiếu guard thì `.length` nổ. const awaiting = (candidates.data ?? []).filter((p) => (p.existingGroups ?? []).length === 0) // [S175 · O8] CÂY 2 CẤP **Dự án › Hạng mục** — anh chốt "cho cây thư mục dự án hạng mục // ra luôn nhé". Trước đây chỉ gom 1 cấp theo dự án, nên một dự án nhiều gói thầu đổ ra // một danh sách phẳng dài, không thấy được ranh giới hạng mục. // 🔴 0 ENDPOINT MỚI: `workItemName` ĐÃ có sẵn trong `KhkkPeCandidateDto` (`khkk.ts:423`) // ⇒ cấp 2 là gom lại trên dữ-liệu đang có, không phải gọi thêm cửa nào. // `Map` giữ thứ tự chèn ở CẢ HAI cấp nên dự án/hạng mục có phiếu mới nhất nổi lên trước // (BE đã trả sẵn theo thứ tự). const byProject = new Map>() for (const pe of awaiting) { const pKey = pe.projectName ?? '(chưa gán dự án)' const wKey = pe.workItemName ?? '(chưa gán hạng mục)' let byWorkItem = byProject.get(pKey) if (!byWorkItem) { byWorkItem = new Map(); byProject.set(pKey, byWorkItem) } const bucket = byWorkItem.get(wKey) if (bucket) bucket.push(pe) else byWorkItem.set(wKey, [pe]) } /** Đếm phiếu của cả một dự án (cộng mọi hạng mục con) — số ở header cấp 1. */ const countProject = (byWorkItem: Map) => [...byWorkItem.values()].reduce((s, arr) => s + arr.length, 0) // [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG // 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH: // loại-trừ (`!== DaDuyet`) sẽ tự động NHẬN mọi phase mới thêm về sau; danh-sách-trắng thì // phase mới phải được khai TƯỜNG MINH mới hiện. Ở màn Thao tác — nơi chỉ nên có việc CÒN // SỬA ĐƯỢC — im lặng nhận thêm là hỏng, nên chọn vế chặt. // 🔸 `ChoDuyet` CŨNG bị loại (khác lượt trước chỉ bỏ `DaDuyet`): phiếu đang trình duyệt thì // người soạn không thao tác gì được nữa. // 🔴 KHAI GIỚI-HẠN (lọc ở FE): endpoint chỉ nhận `phase` ĐƠN, không nhận nhiều ⇒ (1) KHÔNG // dùng `plans.data.total` làm số hiển thị (nó đếm MỌI phase) — đếm từ mảng đã lọc; // (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp // cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE. const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi] const planItems = (plans.data?.items ?? []) .filter((k) => OPEN_PHASES.includes(k.phase)) // [S175] +loc NHOM. `group == null` => khong loc (giu duong cu cho host khong truyen). .filter((k) => group == null || k.approvalGroup === group) return ( ) }