// [W2 KHKK — S161] Màn "Kế hoạch ký kết HĐ — Thao tác" (route `/khkk/create`, 8 leaf nhóm). // File MIRROR SHA256 identical với fe-user counterpart. // // [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện // thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về: // · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE // khuôn PE (`PurchaseEvaluationWorkspacePage.tsx:80`). 🧊 Comment cũ ghi `300px` là số // của bản nháp đầu, KHÔNG bao giờ khớp code sau khi đợt A land 260/320 (S175 vá). // · Panel 1 = danh sách (`KhkkWorkListPanel`) — 2 tầng, MỖI tầng có header dính ĐỈNH // (`sticky top-0`) gom theo dự án. 🧊 Comment cũ ghi "nút hành-động dính đáy" là MÔ TẢ // CỦA PE, không phải của đây: `grep -c Button KhkkWorkListPanel` = **0** (control-dương // `PeListPanel` = 8). Bê nguyên câu mô tả từ khuôn nguồn sang là tự khai một thứ chưa // dựng — S175 vá theo L3 H-5. // · Panel 2 = EmptyState | form tạo | phiếu đang XEM. // 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC" // (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*). // 🔴 Bấm gì cũng Ở LẠI MÀN — 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". Vì vậy panel nhận CALLBACK, không tự `navigate`. // 🧊 Bản S174 đầu gắn 2 tầng này thành cột 3 của màn *Duyệt NCC* — anh bác ("chỗ duyệt NCC // đã golive và đã tốt rồi"); `PurchaseEvaluationWorkspacePage` đã revert về byte `1b3bcaa`. // // [K2 S164 — OG-7] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE đã dùng vài nhóm VẪN hiện, kèm // nhóm đã dùng + nút "Tạo thêm cho nhóm khác". Ẩn đi thì người dùng không phân biệt được // "PE không đủ điều kiện" với "PE đã dùng hết nhóm". // // [K4b S166 — R-5] 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI RENDER. // Khối reset dưới là SET-STATE-TRONG-RENDER CÓ CHỦ ĐÍCH; refactor thành `useEffect` sẽ // tái-hiện vá-5 (đi từ leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG. import { useState, type FormEvent, type ReactNode } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' import { FileCheck, Pencil, X } from 'lucide-react' import { toast } from 'sonner' import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' import { Label } from '@/components/ui/Label' import { Textarea } from '@/components/ui/Textarea' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { useAuth } from '@/contexts/AuthContext' import { KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_LABELS, KhkkPhase, khkkGroupMenuLabel, type CreateKhkkInput, type KhkkDetailDto, type KhkkPeCandidateDto, } from '@/types/khkk' // ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE // `ApplicableType.ContractSigningPlan = 10`). const APPLICABLE_TYPE_KHKK = 10 interface WorkflowDto { id: string code: string name: string isUserSelectable: boolean } // 🧊 [S174] `firstFreeGroup()` ĐÃ GỠ cùng lúc bỏ ô chọn nhóm — nó chỉ tồn tại để tự nhảy // sang nhóm trống, đúng hành-vi anh vừa cấm ("gán cứng theo từng nhóm, ko cho User điều // chỉnh"). Giữ lại thành hàm chết thì lượt sau có người gọi lại. export function KhkkCreatePage() { const navigate = useNavigate() const [searchParams] = useSearchParams() const groupParam = searchParams.get('group') const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('') const [ghiChu, setGhiChu] = useState('') // [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này luôn" // ⇒ nhóm duyệt KHÔNG còn là state: DẪN XUẤT thẳng từ leaf menu (`?group=n`). Không còn // state thì không thể lệch, nên khối đồng-bộ-preset cũ (`lastPresetGroup`) cũng bỏ luôn — // đó là thứ chỉ tồn tại để vá đúng cái lệch này. const approvalGroup = presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT // [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`. const [viewPlanId, setViewPlanId] = useState(null) // [S175 · O7] Bấm bút chì = mở thẳng chế-độ SỬA; bấm thân card = chỉ xem như cũ. const [editPlan, setEditPlan] = useState(false) const { user } = useAuth() // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần. const candidates = useQuery({ queryKey: ['khkk-pe-candidates'], queryFn: async () => (await api.get('/contract-signing-plans/approved-pe-awaiting-plan')).data, }) const workflows = useQuery({ queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }], queryFn: async () => { const res = await api.get<{ types: { applicableType: number; history: WorkflowDto[] }[] }>( '/approval-workflows-v2', { params: { applicableType: APPLICABLE_TYPE_KHKK } }, ) const bucket = res.data.types.find((t) => t.applicableType === APPLICABLE_TYPE_KHKK) return (bucket?.history ?? []).filter((w) => w.isUserSelectable) }, }) // [S174 owner] Quy trình duyệt cũng GÁN CỨNG theo nhóm — mỗi nhóm đúng MỘT quy trình. // 🔴 Chuỗi mã lấy từ NGUỒN BE, không suy từ màn hình: `DbInitializer.cs:609` // `var code = $"KHKK-N{n}"` (vòng `for n = 1..8`, seed idempotent per-Code). // Không khớp ⇒ KHÔNG tự rơi về quy trình khác: chặn tạo + nói rõ thiếu mã nào, vì gán // nhầm quy trình là phiếu đi sai đường duyệt — hỏng nặng hơn là không tạo được. const wantWorkflowCode = `KHKK-N${approvalGroup}` const autoWorkflow = workflows.data?.find( (w) => (w.code ?? '').trim().toUpperCase() === wantWorkflowCode, ) ?? null // Phiếu đang XEM — chỉ tải khi thật sự có cái để xem. const viewPlan = useQuery({ queryKey: ['khkk-detail', viewPlanId], queryFn: async () => (await api.get(`/contract-signing-plans/${viewPlanId}`)).data, enabled: !!viewPlanId, }) const create = useMutation({ mutationFn: async () => { if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') if (!autoWorkflow) throw new Error(`Chưa có quy trình duyệt ${wantWorkflowCode}`) const body: CreateKhkkInput = { peId: purchaseEvaluationId, // [F-2] BE Command field = PeId approvalWorkflowId: autoWorkflow.id, ghiChu: ghiChu.trim() || null, approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE } return (await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)).data }, // [S174] "Header tạo xong thì cho sang chi tiết" — nay KHÔNG rời màn nữa: mở luôn phiếu // vừa tạo ở panel 2 (chỉ xem) và trả form về trắng. Cùng tinh thần "không re-direct". onSuccess: (data) => { toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') setPurchaseEvaluationId('') setGhiChu('') setViewPlanId(data.id) }, onError: (e) => toast.error(getErrorMessage(e)), }) const list = candidates.data ?? [] const selectedPe = list.find((p) => p.id === purchaseEvaluationId) const takenGroups = selectedPe?.existingGroups ?? [] const groupTaken = takenGroups.includes(approvalGroup) const missingCreate = [ !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', !workflows.isLoading && !autoWorkflow && `Quy trình ${wantWorkflowCode} (chưa cấu hình)`, groupTaken && `Phiếu này đã có kế hoạch nhóm N${approvalGroup}`, ].filter(Boolean) as string[] const canSubmit = missingCreate.length === 0 && !!autoWorkflow && !create.isPending // [S174] Bấm PE ở panel 1 = nạp vào form + rời chế-độ xem. 🧊 Khối "nhảy sang nhóm trống // đầu tiên" ĐÃ BỎ: nhóm nay gán cứng theo leaf menu, tự nhảy nhóm = đúng thứ anh vừa cấm. // PE đã có kế hoạch ở nhóm này ⇒ `groupTaken` chặn + nói rõ, người dùng tự sang leaf khác. const pickPe = (peId: string) => { setPurchaseEvaluationId(peId) setViewPlanId(null) } const onSubmit = (e: FormEvent) => { e.preventDefault() create.mutate() } const headerTitle = presetGroup !== null ? `Kế hoạch ký kết HĐ — Thao tác · ${khkkGroupMenuLabel(presetGroup)}` : 'Kế hoạch ký kết HĐ — Thao tác' return (

{headerTitle}

Bấm phiếu ở cột trái — tầng trên lập kế hoạch, tầng dưới chỉ xem.
{/* [S174 · B4] Bề ngang cột về ĐÚNG số của Duyệt NCC (`PurchaseEvaluationWorkspacePage.tsx:80` `260px` / `xl:320px`) — trước đó lead tự đặt 300/340, lệch chuẩn không lý do. [S174 · A2 — VÁ REGRESSION DO CHÍNH LEAD GÂY RA TRONG PHIÊN NÀY] `
` từng mang `hidden … lg:block` ⇒ dưới `lg` chỉ còn panel-1, KHÔNG có form ⇒ **mobile không tạo được phiếu**: bấm phiếu ở cột trái xong thì không có chỗ nào để bấm "Tạo kế hoạch". 🔴 Vá bằng 2 vế, thiếu vế nào cũng hỏng: (1) `
` bỏ `hidden`, hiện mọi khổ; (2) mobile xếp DỌC với panel-1 chốt trần `14rem` — không chốt thì panel-1 (danh sách dài) đẩy form rơi khỏi màn, đúng cái ngõ cụt vừa vá. Từ `lg` trở lên về 1 hàng như cũ. */}
{ setViewPlanId(id); setEditPlan(false) }} selectedPeId={purchaseEvaluationId || null} selectedPlanId={viewPlanId} onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }} />
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */} {viewPlanId && (() => { // [S175 · O7] `readOnly` = KHÔNG bấm bút chì, HOẶC phiếu không đủ điều-kiện sửa. // 🔴 Guard y hệt `KhkkListPage.tsx:147-150` — cùng một câu hỏi "ai được sửa phiếu // này" thì phải cho cùng một câu trả lời ở mọi màn; lệch nhau là đẻ lỗ hổng đúng // kiểu gotcha #82 (một màn khoá, màn kia hở). // 🔸 Đây là rào HIỂN THỊ. Rào THẬT vẫn ở BE (`KeHoachKyKet.Update` + phase guard). const p = viewPlan.data const canEdit = !!p && (p.phase === KhkkPhase.DangSoanThao || p.phase === KhkkPhase.TraLai) && (!user?.id || p.drafterUserId === user.id) const readOnly = !editPlan || !canEdit return (
{readOnly ? (editPlan && !canEdit ? 'Kế hoạch này không sửa được (đã gửi duyệt / đã duyệt, hoặc bạn không phải người soạn) — chỉ xem.' : 'Đang xem kế hoạch — chỉ xem. Bấm bút chì ở danh sách để sửa.') : '✎ Đang SỬA kế hoạch — thay đổi lưu thẳng vào phiếu.'}
{readOnly && canEdit && ( )} {!readOnly && ( )}
{viewPlan.isLoading &&
Đang tải…
} {!viewPlan.isLoading && !viewPlan.data && (
Không tìm thấy phiếu.
)} {viewPlan.data && ( viewPlan.refetch()} /> )}
) })()} {/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */} {!viewPlanId && !purchaseEvaluationId && ( )} {/* (c) Đã chọn PE — form tạo. */} {!viewPlanId && purchaseEvaluationId && (

Lập kế hoạch ký kết HĐ mới

Kiểm Section 1 + 2 → bấm “Tạo kế hoạch” → sau đó dòng giá NCC / căn cứ hồ sơ / ý kiến mở khóa.

{/* Danh sách chọn nằm ở PANEL 1 — ở đây chỉ XÁC NHẬN cái đang chọn, để không có hai chỗ chọn cùng một thứ (2 nguồn sự thật trên cùng màn). */}
{selectedPe?.maPhieu ?? '—'} {selectedPe?.tenGoiThau ?? '(đang tải…)'}
{selectedPe?.projectName ?? '—'} {selectedPe?.workItemName ? ` · ${selectedPe.workItemName}` : ''}
{(selectedPe?.winnerSupplierNames?.length ?? 0) > 0 && (
{selectedPe!.winnerSupplierNames.map((n) => ( {n} ))}
)} {takenGroups.length > 0 && (
Đã có kế hoạch nhóm: {takenGroups.map((g) => ( N{g} ))}
)}
{/* [S174 owner] "gán cứng luôn theo từng nhóm, ko cho User điều chỉnh chỗ này luôn" ⇒ CẢ HAI ô thành GIÁ-TRỊ CỐ ĐỊNH, không còn `Select`. Cố ý KHÔNG dùng `