[CLAUDE] Contract: man KHKK Thao tac thanh host 2-panel giong Duyet NCC + REVERT cot da chen nham vao man PE
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m40s

Anh bac ban truoc: "Bo phan nay di cho duyet NCC da golive va da tot roi, y tao la dang
muon may lam giong giao dien thao tac cua Duyet NCC".

REVERT (quan trong nhat)
- `PurchaseEvaluationWorkspacePage` x2 app tro ve DUNG BYTE cua 1b3bcaa (grid 2 panel,
  0 import, 0 markup thua). Verify bang sha256 vs `git show 1b3bcaa:` + control-duong
  grep 0 hit ten panel trong pages/pe/.
- Bai hoc ghi vao dau file panel: "lam giong man X" != "sua man X". Ban dau doc anh 2/3
  (anh khoanh o man PE) thanh "chen them cot vao man PE"; anh 7 moi lo cho dat that la
  cot TRAI cua man Thao tac KHKK.

DI DOI
- `components/pe/PeKhkkSidePanel.tsx` -> `components/khkk/KhkkWorkListPanel.tsx` (git mv,
  giu lich su) — no phuc vu KHKK chu khong phai PE.

MAN KHKK — THAO TAC (`/khkk/create`, 8 leaf nhom) doi tu trang-giay-cuon sang HOST 2-PANEL
- Khuon be nguyen tu `PurchaseEvaluationWorkspacePage`: header 1 dong + grid
  `lg:grid-cols-[300px_1fr]` + Panel1 danh sach + nut dinh day + Panel2 noi dung.
- Panel 1 = 2 TANG dung anh anh khoanh:
    tang TREN "Cot danh sach Duyet NCC" — PE da duyet CHUA lap KHKK, gom theo DU AN;
                                          bam = nap thang vao form tao (tao luon KHKK).
    tang DUOI "Cot KHKK da tao"        — bam = XEM tai cho, read-only.
- 🔴 KHONG re-direct o BAT KY duong nao (anh chot "cho hien phieu do luon, chi view la dc,
  ko re-direct ve phieu goc"): panel nhan CALLBACK onPickPe/onPickPlan thay vi tu navigate.
  Van con nut phu "Mo trang day du" cho ai muon roi man — la lua chon, khong phai ep buoc.
- Section 1 cua form doi tu DANH SACH CHON thanh KHOI XAC NHAN phieu dang chon — danh sach
  da o Panel 1, de 2 cho chon cung 1 thu = 2 nguon su that tren cung mot man.
- Tao xong KHONG navigate('/khkk/:id') nua ma mo luon phieu vua tao o Panel 2 + tra form ve
  trang. Cung tinh than "khong roi man".
- Nut "Them moi" (dinh day Panel 1) = tra Panel 2 ve form trang.
- Gate `KeHoachKyKet.Read` giu nguyen trong panel (gotcha #85); thieu quyen thi NOI RA,
  va nut hanh-dong VAN hien de man khong thanh ngo cut.

Verify: build 2 app PASS; mirror SHA256 3/3 cap KHOP; PE workspace 0 du luong (byte-exact
vs 1b3bcaa) + control-duong grep 0 hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 15:26:35 +07:00
parent e6347fd42d
commit 3f339c02c6
6 changed files with 662 additions and 560 deletions

View File

@ -1,9 +1,15 @@
// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50: // [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, // · 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 — đây là "việc còn phải làm"; // 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 — đây là "việc đã làm rồi". // · 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).
// Anh chốt: "Thử ý tưởng 1 cột 2 tầng thử, 50% tầng trên là duyệt NCC, còn 50% tầng dưới // File MIRROR SHA256 identical với fe-user counterpart.
// là KHKK". 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: // 🔴 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` // · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage`
@ -18,7 +24,6 @@
// 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 // 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ì". // 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 { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileCheck, FileSignature, Lock } from 'lucide-react' import { FileCheck, FileSignature, Lock } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -68,8 +73,23 @@ function Tier({
) )
} }
export function PeKhkkSidePanel() { export function KhkkWorkListPanel({
const navigate = useNavigate() onPickPe,
onPickPlan,
selectedPeId,
selectedPlanId,
footer,
}: {
/** 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
/** Bm KHKK tng dưi host m phiếu đó panel 2, CH XEM. Anh cht: "click vào phiếu
* nào thì cho hiện phiếu đó luôn, chỉ view đc, ko re-direct về phiếu gốc". */
onPickPlan: (planId: string) => void
selectedPeId?: string | null
selectedPlanId?: string | null
/** Nút "+ Thêm mới" dính đáy — mirror `PeListPanel` của màn Duyệt NCC — Thao tác. */
footer?: React.ReactNode
}) {
const { can } = usePermission() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚ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 canKhkk = can('KeHoachKyKet', 'Read')
@ -109,7 +129,7 @@ export function PeKhkkSidePanel() {
const planItems = plans.data?.items ?? [] const planItems = plans.data?.items ?? []
return ( return (
<aside className="hidden min-h-0 flex-col overflow-hidden border-l border-slate-200 bg-white xl:flex"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
{!canKhkk ? ( {!canKhkk ? (
// Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền. // Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền.
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
@ -158,11 +178,14 @@ export function PeKhkkSidePanel() {
<li key={pe.id}> <li key={pe.id}>
<button <button
type="button" type="button"
// Bấm = đi thẳng tới màn lập kế hoạch. Không preset `?group=` vì // Bấm = nạp THẲNG phiếu này vào form tạo bên panel 2. Không điều hướng,
// PE này chưa dùng nhóm nào ⇒ để màn tạo giữ default của nó. // không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
onClick={() => navigate('/khkk/create')} onClick={() => onPickPe(pe.id)}
title="Lập kế hoạch ký kết cho phiếu này" title="Lập kế hoạch ký kết cho phiếu này"
className="block w-full px-3 py-2 text-left transition hover:bg-amber-50/60" className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-amber-50/60',
selectedPeId === pe.id && 'bg-amber-50 ring-1 ring-inset ring-amber-300',
)}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span> <span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
@ -212,9 +235,14 @@ export function PeKhkkSidePanel() {
<li key={k.id}> <li key={k.id}>
<button <button
type="button" type="button"
onClick={() => navigate(`/khkk/${k.id}`)} // 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện
title="Mở kế hoạch ký kết" // phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc".
className="block w-full px-3 py-2 text-left transition hover:bg-violet-50/60" onClick={() => onPickPlan(k.id)}
title="Xem kế hoạch ký kết này (chỉ xem)"
className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60',
selectedPlanId === k.id && 'bg-violet-50 ring-1 ring-inset ring-violet-300',
)}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span> <span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span>
@ -247,6 +275,11 @@ export function PeKhkkSidePanel() {
</Tier> </Tier>
</> </>
)} )}
{/* Nút hành-đng dính ĐÁY, ngoài vùng cun ca 2 tng mirror "+ Thêm mi" ca
`PeListPanel` (màn Duyệt NCC Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột
không dựng đưc thì vẫn phải còn đưng bấm, không thì màn thành ngõ cụt. */}
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>}
</aside> </aside>
) )
} }

View File

@ -1,46 +1,51 @@
// [W2 KHKK — S161 2026-07-29] Lập "Kế hoạch ký kết HĐ" từ phiếu Duyệt NCC đã duyệt. // [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. // File MIRROR SHA256 identical với fe-user counterpart.
// //
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE // [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin // thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE // · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`;
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines. // · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy;
// · 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`.
// //
// [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG // [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
// hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) — // 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
// anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết": // "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
// · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
// · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
// · thanh cuối = hint TRÁI liệt kê ĐÍCH DANH trường còn thiếu, nút PHẢI.
// 🔸 Vế "sang chi tiết" VỐN ĐÃ ĐÚNG từ S161 (`onSuccess` → `navigate('/khkk/:id')`) — đợt
// này KHÔNG đụng luồng đó, chỉ đổi hình. Ghi ra để người sau khỏi tưởng vừa thêm mới.
// //
// [K2 S164OG-7 UI-hướng-1] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm // [K4b S166R-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.
// danh sách nhóm đã dùng + nút phụ "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). // Khối reset dưới là SET-STATE-TRONG-RENDER CÓ CHỦ ĐÍCH; refactor thành `useEffect` sẽ
// Ẩn PE đ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 // 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.
// hết nhóm".
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { CheckCircle2, Plus } from 'lucide-react' import { FileCheck, Plus, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { import {
KHKK_APPROVAL_GROUPS, KHKK_APPROVAL_GROUPS,
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
khkkGroupMenuLabel,
type CreateKhkkInput, type CreateKhkkInput,
type KhkkDetailDto,
type KhkkPeCandidateDto, type KhkkPeCandidateDto,
} from '@/types/khkk' } from '@/types/khkk'
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE // ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1). // `ApplicableType.ContractSigningPlan = 10`).
const APPLICABLE_TYPE_KHKK = 10 const APPLICABLE_TYPE_KHKK = 10
interface WorkflowDto { interface WorkflowDto {
@ -50,14 +55,8 @@ interface WorkflowDto {
isUserSelectable: boolean isUserSelectable: boolean
} }
function formatDate(iso: string): string { // Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm) — để ô chọn nhóm không đứng
const d = new Date(iso) // yên ở một nhóm chắc chắn 409.
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
// một nhóm chắc chắn 409.
function firstFreeGroup(taken: number[]): number | null { function firstFreeGroup(taken: number[]): number | null {
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
} }
@ -66,10 +65,6 @@ export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
// [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`)
// hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn
// đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ.
// Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải.
const groupParam = searchParams.get('group') const groupParam = searchParams.get('group')
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
@ -77,29 +72,25 @@ export function KhkkCreatePage() {
const [approvalWorkflowId, setApprovalWorkflowId] = useState('') const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT) const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
// [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<string | null>(null)
// Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route // Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với
// `/khkk/create`, nhảy leaf→leaf không dng lại component nên giá trị khởi tạo ở // `approvalGroup` thì người dùng tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL.
// trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi.
// 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu so với `approvalGroup` thì người dùng
// tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL, không bấm đi đâu được.
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup) const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
if (presetGroup !== lastPresetGroup) { if (presetGroup !== lastPresetGroup) {
setLastPresetGroup(presetGroup) setLastPresetGroup(presetGroup)
if (presetGroup !== null) setApprovalGroup(presetGroup) if (presetGroup !== null) setApprovalGroup(presetGroup)
} }
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
const candidates = useQuery({ const candidates = useQuery({
queryKey: ['khkk-pe-candidates'], queryKey: ['khkk-pe-candidates'],
queryFn: async () => queryFn: async () =>
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data, (await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
}) })
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
// KHÔNG phải flat array. Lọc isUserSelectable = quy trình admin đã ghim cho user pick.
const workflows = useQuery({ const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }], queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
queryFn: async () => { queryFn: async () => {
@ -112,6 +103,13 @@ export function KhkkCreatePage() {
}, },
}) })
// 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<KhkkDetailDto>(`/contract-signing-plans/${viewPlanId}`)).data,
enabled: !!viewPlanId,
})
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
@ -120,37 +118,27 @@ export function KhkkCreatePage() {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId, approvalWorkflowId,
ghiChu: ghiChu.trim() || null, ghiChu: ghiChu.trim() || null,
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
approvalGroup,
} }
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body) return (await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)).data
return res.data
}, },
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161. // [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) => { onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`) setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
setViewPlanId(data.id)
}, },
onError: (e) => toast.error(getErrorMessage(e)), onError: (e) => toast.error(getErrorMessage(e)),
}) })
const onSubmit = (e: FormEvent) => {
e.preventDefault()
create.mutate()
}
const list = candidates.data ?? [] const list = candidates.data ?? []
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
// cũ còn nằm trong cache React Query trước khi K2 deploy.
const selectedPe = list.find((p) => p.id === purchaseEvaluationId) const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
const takenGroups = selectedPe?.existingGroups ?? [] const takenGroups = selectedPe?.existingGroups ?? []
const groupTaken = takenGroups.includes(approvalGroup) const groupTaken = takenGroups.includes(approvalGroup)
// [S174] Khuôn `PeWorkspaceCreateView.tsx:145-150`: liệt kê ĐÍCH DANH cái còn thiếu thay
// vì chỉ khoá nút — nút xám không nói được vì sao nó xám.
const missingCreate = [ const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt', !approvalWorkflowId && 'Quy trình duyệt',
@ -158,221 +146,259 @@ export function KhkkCreatePage() {
].filter(Boolean) as string[] ].filter(Boolean) as string[]
const canSubmit = missingCreate.length === 0 && !create.isPending const canSubmit = missingCreate.length === 0 && !create.isPending
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã // Bấm PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. // nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
const selectPe = (pe: KhkkPeCandidateDto) => { const pickPe = (peId: string) => {
setPurchaseEvaluationId(pe.id) setPurchaseEvaluationId(peId)
const taken = pe.existingGroups ?? [] setViewPlanId(null)
const taken = list.find((p) => p.id === peId)?.existingGroups ?? []
if (taken.includes(approvalGroup)) { if (taken.includes(approvalGroup)) {
const free = firstFreeGroup(taken) const free = firstFreeGroup(taken)
if (free !== null) setApprovalGroup(free) if (free !== null) setApprovalGroup(free)
} }
} }
return ( const onSubmit = (e: FormEvent) => {
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm"> e.preventDefault()
{/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */} create.mutate()
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3"> }
<div>
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
<p className="mt-0.5 text-[12px] text-slate-500">
Nhập Section 1 + 2 bấm <strong>&ldquo;Tạo kế hoạch&rdquo;</strong> sau đó dòng giá NCC / căn cứ hồ / ý kiến mở khóa.
</p>
</div>
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
</div>
<div className="divide-y divide-slate-200"> const headerTitle = presetGroup !== null
<Section title="1. Phiếu Duyệt NCC nguồn *"> ? `Kế hoạch ký kết HĐ — Thao tác · ${khkkGroupMenuLabel(presetGroup)}`
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>} : 'Kế hoạch ký kết HĐ — Thao tác'
{!candidates.isLoading && list.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground"> return (
Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu. <div className="flex h-[calc(100vh-4rem)] flex-col">
<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 min-w-0 items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" />
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
</div>
<div className="text-[12px] text-slate-500">
Bấm phiếu cột trái tầng trên lập kế hoạch, tầng dưới chỉ xem.
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]">
<KhkkWorkListPanel
onPickPe={pickPe}
onPickPlan={setViewPlanId}
selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId}
footer={
<Button
type="button"
className="w-full"
// "Thêm mới" = trả panel 2 về form TRẮNG. Không điều hướng đi đâu.
onClick={() => {
setViewPlanId(null)
setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
}}
>
<Plus className="mr-2 h-4 w-4" />
Thêm mới
</Button>
}
/>
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
{viewPlanId && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2">
<span className="text-[12px] font-medium text-violet-800">
Đang xem kế hoạch chỉ xem, không sửa màn Thao tác.
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
Mở trang đy đ
</Button>
<Button variant="ghost" size="sm" onClick={() => setViewPlanId(null)}>
<X className="mr-1.5 h-3.5 w-3.5" />
Về form tạo
</Button>
</div>
</div>
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{!viewPlan.isLoading && !viewPlan.data && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)}
{viewPlan.data && (
<KhkkDetailContent plan={viewPlan.data} readOnly onChanged={() => viewPlan.refetch()} />
)}
</div> </div>
)} )}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
const taken = pe.existingGroups ?? []
const free = firstFreeGroup(taken)
return (
<div key={pe.id} className="space-y-1">
<button
type="button"
onClick={() => selectPe(pe)}
className={cn(
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
selected
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-300'
: 'border-slate-200 hover:border-brand-300 hover:bg-slate-50',
)}
>
<CheckCircle2
className={cn('mt-0.5 h-4 w-4 shrink-0', selected ? 'text-brand-600' : 'text-slate-300')}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="font-medium text-brand-800">{pe.tenGoiThau}</span>
</span>
<span className="mt-0.5 block text-xs text-slate-500">
{pe.projectName ?? '—'}
{pe.workItemName ? ` · ${pe.workItemName}` : ''} · {formatDate(pe.createdAt)}
</span>
<span className="mt-1 flex flex-wrap gap-1">
{pe.winnerSupplierNames.length === 0 ? (
<span className="text-xs italic text-slate-400">Chưa NCC trúng thầu</span>
) : (
pe.winnerSupplierNames.map((n) => (
<span
key={n}
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
>
{n}
</span>
))
)}
</span>
</span>
</button>
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm {/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng {!viewPlanId && !purchaseEvaluationId && (
button là HTML không hợp lệ). */} <EmptyState
{taken.length > 0 && ( icon={FileCheck}
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs"> title="Chọn phiếu Duyệt NCC để lập kế hoạch"
<span className="text-slate-500">Đã kế hoạch nhóm:</span> description="Bấm một phiếu ở tầng trên cột trái để lập kế hoạch ký kết, hoặc bấm một kế hoạch ở tầng dưới để xem."
{taken.map((g) => ( />
<span )}
key={g}
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800" {/* (c) Đã chọn PE — form tạo. */}
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`} {!viewPlanId && purchaseEvaluationId && (
> <form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
N{g} <div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
</span> <div>
))} <h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
{free === null ? ( <p className="mt-0.5 text-[12px] text-slate-500">
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span> Kiểm Section 1 + 2 bấm <strong>&ldquo;Tạo kế hoạch&rdquo;</strong> sau đó dòng giá NCC / căn cứ hồ / ý kiến mở khóa.
</p>
</div>
</div>
<div className="divide-y divide-slate-200">
<Section title="1. Phiếu Duyệt NCC nguồn *">
{/* 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). */}
<div className="rounded-lg border border-brand-300 bg-brand-50/60 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-brand-700">{selectedPe?.maPhieu ?? '—'}</span>
<span className="font-medium text-brand-800">{selectedPe?.tenGoiThau ?? '(đang tải…)'}</span>
</div>
<div className="mt-0.5 text-xs text-slate-500">
{selectedPe?.projectName ?? '—'}
{selectedPe?.workItemName ? ` · ${selectedPe.workItemName}` : ''}
</div>
{(selectedPe?.winnerSupplierNames?.length ?? 0) > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{selectedPe!.winnerSupplierNames.map((n) => (
<span
key={n}
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
>
{n}
</span>
))}
</div>
)}
{takenGroups.length > 0 && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-slate-500">Đã kế hoạch nhóm:</span>
{takenGroups.map((g) => (
<span
key={g}
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
>
N{g}
</span>
))}
</div>
)}
</div>
</Section>
<Section title="2. Nhóm duyệt & quy trình">
<div className="grid gap-3 md:grid-cols-2">
<div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label>
<Select
id="approvalGroup"
value={approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))}
>
{KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g)
return (
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn nhóm khác.
</p>
) : ( ) : (
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}> <p className="mt-1 text-[11px] text-slate-500">
<Plus className="mr-1.5 h-3.5 w-3.5" /> Mỗi phiếu Duyệt NCC lập đưc 1 kế hoạch cho MỖI nhóm duyệt
Tạo thêm cho nhóm khác </p>
</Button>
)} )}
</div> </div>
<div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
<Select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
>
<option value=""> Chọn quy trình duyệt </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>{w.code} - {w.name}</option>
))}
</Select>
{workflows.data && workflows.data.length === 0 ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình admin đã ghim cho user chọn
</p>
)}
</div>
</div>
</Section>
<Section title="3. Ghi chú">
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
</Section>
</div>
{/* Hint TRÁI kể rõ còn thiếu gì, nút PHẢI. `groupTaken` chặn ở FE lượt bấm chắc
chắn ăn 409 của BE (`Features.cs:395-402`) — BE vẫn là rào thật. */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="min-w-0 text-[11px]">
{missingCreate.length > 0 ? (
<span className="text-red-600">
Chưa đ đ tạo kế hoạch còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
</span>
) : (
<span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
)} )}
</div> </div>
) <div className="flex shrink-0 items-center gap-2">
})} <Button
</div> type="button"
</Section> variant="ghost"
className="text-xs"
<Section title="2. Nhóm duyệt & quy trình"> onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }}
<div className="grid gap-3 md:grid-cols-2"> >
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP, Hủy
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable — </Button>
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */} <Button type="submit" disabled={!canSubmit}>
<div> {create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label> </Button>
<Select </div>
id="approvalGroup" </div>
value={approvalGroup} </form>
onChange={(e) => setApprovalGroup(Number(e.target.value))}
>
{KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g)
return (
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu Duyệt NCC này đã kế hoạch kết cho nhóm N{approvalGroup} chọn nhóm khác.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Mỗi phiếu Duyệt NCC lập đưc 1 kế hoạch cho MỖI nhóm duyệt
</p>
)}
</div>
<div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
<Select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
>
<option value=""> Chọn quy trình duyệt </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</Select>
{workflows.data && workflows.data.length === 0 ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
</p>
)}
</div>
</div>
</Section>
<Section title="3. Ghi chú">
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
</Section>
</div>
{/* Thanh cuối — hint TRÁI kể rõ còn thiếu gì, nút PHẢI (mirror `:318-337`).
`groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ đỡ 1 vòng mạng. */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="min-w-0 text-[11px]">
{missingCreate.length > 0 ? (
<span className="text-red-600">
Chưa đ đ tạo kế hoạch còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
</span>
) : (
<span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
)} )}
</div> </main>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
<Button type="submit" disabled={!canSubmit}>
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
</Button>
</div>
</div> </div>
</form> </div>
) )
} }
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do // Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do vì sao
// vì sao bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`). // bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
function Section({ title, children }: { title: string; children: ReactNode }) { function Section({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<section className="px-5 py-4"> <section className="px-5 py-4">

View File

@ -15,7 +15,6 @@ import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react' import { ClipboardCheck } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeKhkkSidePanel } from '@/components/pe/PeKhkkSidePanel'
import { PeListPanel } from '@/components/pe/PeListPanel' import { PeListPanel } from '@/components/pe/PeListPanel'
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView' import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -78,10 +77,7 @@ export function PurchaseEvaluationWorkspacePage() {
</div> </div>
</header> </header>
{/* [S174 owner] Cột 3 (`PeKhkkSidePanel`, 2 tầng 50/50) CHỈ mọc ở `xl` — thêm nó vào <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]">
`lg` sẽ bóp panel giữa (nơi nhập liệu thật) xuống dưới ngưỡng dùng được. Ở `lg`
bố cục giữ ĐÚNG 2 panel như trước, không đổi một pixel nào. */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr_320px]">
{/* Panel 1: List pure picker + sticky create + pencil edit hover. {/* Panel 1: List pure picker + sticky create + pencil edit hover.
Workspace chỉ list phiếu Bản nháp (DangSoanThao) — đã gửi duyệt rồi Workspace chỉ list phiếu Bản nháp (DangSoanThao) — đã gửi duyệt rồi
không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */} không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */}
@ -134,10 +130,6 @@ export function PurchaseEvaluationWorkspacePage() {
/> />
)} )}
</main> </main>
{/* Panel 3: 1 cột 2 tầng — trên = PE đã duyệt CHƯA lập KHKK (gom theo dự án),
dưới = KHKK đã tạo. Tự gate `KeHoachKyKet.Read` bên trong (gotcha #85). */}
<PeKhkkSidePanel />
</div> </div>
{/* Mobile fallback: nếu không lg, redirect về detail page */} {/* Mobile fallback: nếu không lg, redirect về detail page */}

View File

@ -1,9 +1,15 @@
// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50: // [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, // · 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 — đây là "việc còn phải làm"; // 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 — đây là "việc đã làm rồi". // · 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).
// Anh chốt: "Thử ý tưởng 1 cột 2 tầng thử, 50% tầng trên là duyệt NCC, còn 50% tầng dưới // File MIRROR SHA256 identical với fe-user counterpart.
// là KHKK". 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: // 🔴 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` // · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage`
@ -18,7 +24,6 @@
// 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 // 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ì". // 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 { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileCheck, FileSignature, Lock } from 'lucide-react' import { FileCheck, FileSignature, Lock } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -68,8 +73,23 @@ function Tier({
) )
} }
export function PeKhkkSidePanel() { export function KhkkWorkListPanel({
const navigate = useNavigate() onPickPe,
onPickPlan,
selectedPeId,
selectedPlanId,
footer,
}: {
/** 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
/** Bm KHKK tng dưi host m phiếu đó panel 2, CH XEM. Anh cht: "click vào phiếu
* nào thì cho hiện phiếu đó luôn, chỉ view đc, ko re-direct về phiếu gốc". */
onPickPlan: (planId: string) => void
selectedPeId?: string | null
selectedPlanId?: string | null
/** Nút "+ Thêm mới" dính đáy — mirror `PeListPanel` của màn Duyệt NCC — Thao tác. */
footer?: React.ReactNode
}) {
const { can } = usePermission() const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị). // 🔴 ĐÚ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 canKhkk = can('KeHoachKyKet', 'Read')
@ -109,7 +129,7 @@ export function PeKhkkSidePanel() {
const planItems = plans.data?.items ?? [] const planItems = plans.data?.items ?? []
return ( return (
<aside className="hidden min-h-0 flex-col overflow-hidden border-l border-slate-200 bg-white xl:flex"> <aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
{!canKhkk ? ( {!canKhkk ? (
// Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền. // Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền.
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
@ -158,11 +178,14 @@ export function PeKhkkSidePanel() {
<li key={pe.id}> <li key={pe.id}>
<button <button
type="button" type="button"
// Bấm = đi thẳng tới màn lập kế hoạch. Không preset `?group=` vì // Bấm = nạp THẲNG phiếu này vào form tạo bên panel 2. Không điều hướng,
// PE này chưa dùng nhóm nào ⇒ để màn tạo giữ default của nó. // không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
onClick={() => navigate('/khkk/create')} onClick={() => onPickPe(pe.id)}
title="Lập kế hoạch ký kết cho phiếu này" title="Lập kế hoạch ký kết cho phiếu này"
className="block w-full px-3 py-2 text-left transition hover:bg-amber-50/60" className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-amber-50/60',
selectedPeId === pe.id && 'bg-amber-50 ring-1 ring-inset ring-amber-300',
)}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span> <span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
@ -212,9 +235,14 @@ export function PeKhkkSidePanel() {
<li key={k.id}> <li key={k.id}>
<button <button
type="button" type="button"
onClick={() => navigate(`/khkk/${k.id}`)} // 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện
title="Mở kế hoạch ký kết" // phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc".
className="block w-full px-3 py-2 text-left transition hover:bg-violet-50/60" onClick={() => onPickPlan(k.id)}
title="Xem kế hoạch ký kết này (chỉ xem)"
className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60',
selectedPlanId === k.id && 'bg-violet-50 ring-1 ring-inset ring-violet-300',
)}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span> <span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span>
@ -247,6 +275,11 @@ export function PeKhkkSidePanel() {
</Tier> </Tier>
</> </>
)} )}
{/* Nút hành-đng dính ĐÁY, ngoài vùng cun ca 2 tng mirror "+ Thêm mi" ca
`PeListPanel` (màn Duyệt NCC Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột
không dựng đưc thì vẫn phải còn đưng bấm, không thì màn thành ngõ cụt. */}
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>}
</aside> </aside>
) )
} }

View File

@ -1,46 +1,51 @@
// [W2 KHKK — S161 2026-07-29] Lập "Kế hoạch ký kết HĐ" từ phiếu Duyệt NCC đã duyệt. // [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. // File MIRROR SHA256 identical với fe-user counterpart.
// //
// 3 section: (1) chọn phiếu PE nguồn — phiếu DaDuyet có NCC trúng thầu (BE lọc, FE // [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
// không tự đoán); (2) NHÓM DUYỆT N1..N8 + quy trình duyệt V2 ApplicableType=10 admin // thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
// đã ghim; (3) ghi chú. Dòng giá per-NCC do BE tự sinh từ winner của phiếu PE // · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`;
// (snapshot `PeReferenceAmount`) — FE KHÔNG gửi lines. // · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy;
// · 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`.
// //
// [S174 owner] ĐỔI KHUÔN: bỏ `PageHeader` + 3 khối `.card-accent` rời, chuyển sang ĐÚNG // [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
// hình "Tạo phiếu Duyệt NCC mới" (`components/pe/PeWorkspaceCreateView.tsx:152-352`) — // 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
// anh chốt "Tạo Header → tương tự NCC → sau khi Header tạo xong thì cho sang chi tiết": // "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
// · MỘT card `rounded-lg border bg-white shadow-sm`, các section ngăn bằng `divide-y`;
// · thanh đầu = tiêu-đề + 1 dòng kể LUỒNG (nhập gì → bấm gì → cái gì mở khoá) + "← Hủy";
// · thanh cuối = hint TRÁI liệt kê ĐÍCH DANH trường còn thiếu, nút PHẢI.
// 🔸 Vế "sang chi tiết" VỐN ĐÃ ĐÚNG từ S161 (`onSuccess` → `navigate('/khkk/:id')`) — đợt
// này KHÔNG đụng luồng đó, chỉ đổi hình. Ghi ra để người sau khỏi tưởng vừa thêm mới.
// //
// [K2 S164OG-7 UI-hướng-1] Rào là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ PE vẫn hiện, kèm // [K4b S166R-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.
// danh sách nhóm đã dùng + nút phụ "Tạo thêm cho nhóm khác" (preset sang nhóm còn trống). // Khối reset dưới là SET-STATE-TRONG-RENDER CÓ CHỦ ĐÍCH; refactor thành `useEffect` sẽ
// Ẩn PE đ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 // 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.
// hết nhóm".
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { CheckCircle2, Plus } from 'lucide-react' import { FileCheck, Plus, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { import {
KHKK_APPROVAL_GROUPS, KHKK_APPROVAL_GROUPS,
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
khkkGroupMenuLabel,
type CreateKhkkInput, type CreateKhkkInput,
type KhkkDetailDto,
type KhkkPeCandidateDto, type KhkkPeCandidateDto,
} from '@/types/khkk' } from '@/types/khkk'
// ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE // ApplicableType của workflow V2 dành cho Kế hoạch ký kết (mirror BE
// `ApplicableType.ContractSigningPlan = 10`, Designer fe-admin đã mở từ W1). // `ApplicableType.ContractSigningPlan = 10`).
const APPLICABLE_TYPE_KHKK = 10 const APPLICABLE_TYPE_KHKK = 10
interface WorkflowDto { interface WorkflowDto {
@ -50,14 +55,8 @@ interface WorkflowDto {
isUserSelectable: boolean isUserSelectable: boolean
} }
function formatDate(iso: string): string { // Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm) — để ô chọn nhóm không đứng
const d = new Date(iso) // yên ở một nhóm chắc chắn 409.
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
// Nhóm trống ĐẦU TIÊN của một PE (null = đã dùng hết 8 nhóm). Dùng cả cho nút
// "Tạo thêm cho nhóm khác" lẫn lúc bấm chọn PE — để ô chọn nhóm không đứng yên ở
// một nhóm chắc chắn 409.
function firstFreeGroup(taken: number[]): number | null { function firstFreeGroup(taken: number[]): number | null {
return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null return KHKK_APPROVAL_GROUPS.find((g) => !taken.includes(g)) ?? null
} }
@ -66,10 +65,6 @@ export function KhkkCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
// [K4b S166 — vá-6] Vào từ leaf "Thao tác" của một nhóm (`/khkk/create?group={n}`)
// hoặc từ nút "Lập kế hoạch" trên màn danh sách đang lọc nhóm ⇒ ô chọn nhóm mở sẵn
// đúng nhóm đó. Không có tham số (vào thẳng /khkk/create) thì giữ nguyên default cũ.
// Chỉ nhận 1..8, rác coi như không có — ô chọn không được phép mang giá trị ngoài dải.
const groupParam = searchParams.get('group') const groupParam = searchParams.get('group')
const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null const presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
@ -77,29 +72,25 @@ export function KhkkCreatePage() {
const [approvalWorkflowId, setApprovalWorkflowId] = useState('') const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT) const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
// [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<string | null>(null)
// Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route // Đổi leaf nhóm ⇒ đồng bộ ô chọn nhóm. CHỈ đổi khi CHÍNH THAM SỐ URL đổi — so với
// `/khkk/create`, nhảy leaf→leaf không dng lại component nên giá trị khởi tạo ở // `approvalGroup` thì người dùng tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL.
// trên KHÔNG chạy lại. Đồng bộ lại ngay trong render khi tham số nhóm đổi.
// 🔴 Chỉ đổi khi CHÍNH THAM SỐ URL đổi — nếu so với `approvalGroup` thì người dùng
// tự chọn nhóm khác sẽ bị kéo ngược về nhóm của URL, không bấm đi đâu được.
const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup) const [lastPresetGroup, setLastPresetGroup] = useState(presetGroup)
if (presetGroup !== lastPresetGroup) { if (presetGroup !== lastPresetGroup) {
setLastPresetGroup(presetGroup) setLastPresetGroup(presetGroup)
if (presetGroup !== null) setApprovalGroup(presetGroup) if (presetGroup !== null) setApprovalGroup(presetGroup)
} }
// Phiếu PE đủ điều kiện: Phase=DaDuyet VÀ có ≥1 NCC trúng thầu (BE lọc — khớp // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
// ĐÚNG 2 rào còn lại của Create nên picker không mời bấm vào thứ chắc chắn 409).
// [K2 S164] Vị ngữ "chưa có kế hoạch sống" ĐÃ BỎ (`Features.cs:1291-1295`).
const candidates = useQuery({ const candidates = useQuery({
queryKey: ['khkk-pe-candidates'], queryKey: ['khkk-pe-candidates'],
queryFn: async () => queryFn: async () =>
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data, (await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
}) })
// Endpoint trả AwAdminOverviewDto { types: [{ applicableType, history: [...] }] } —
// KHÔNG phải flat array. Lọc isUserSelectable = quy trình admin đã ghim cho user pick.
const workflows = useQuery({ const workflows = useQuery({
queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }], queryKey: ['approval-workflows-v2', { applicableType: APPLICABLE_TYPE_KHKK, isUserSelectable: true }],
queryFn: async () => { queryFn: async () => {
@ -112,6 +103,13 @@ export function KhkkCreatePage() {
}, },
}) })
// 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<KhkkDetailDto>(`/contract-signing-plans/${viewPlanId}`)).data,
enabled: !!viewPlanId,
})
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn') if (!purchaseEvaluationId) throw new Error('Vui lòng chọn phiếu Duyệt NCC nguồn')
@ -120,37 +118,27 @@ export function KhkkCreatePage() {
peId: purchaseEvaluationId, // [F-2] BE Command field = PeId peId: purchaseEvaluationId, // [F-2] BE Command field = PeId
approvalWorkflowId, approvalWorkflowId,
ghiChu: ghiChu.trim() || null, ghiChu: ghiChu.trim() || null,
// [K2 S164] Gửi TƯỜNG MINH (BE cho phép bỏ trống = nhóm 1, nhưng ô chọn đã approvalGroup, // [K2] gửi TƯỜNG MINH, không dựa default ngầm của BE
// hiện số nào thì phiếu phải vào đúng số đó — không dựa default ngầm).
approvalGroup,
} }
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body) return (await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)).data
return res.data
}, },
// 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161. // [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) => { onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết') toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`) setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
setViewPlanId(data.id)
}, },
onError: (e) => toast.error(getErrorMessage(e)), onError: (e) => toast.error(getErrorMessage(e)),
}) })
const onSubmit = (e: FormEvent) => {
e.preventDefault()
create.mutate()
}
const list = candidates.data ?? [] const list = candidates.data ?? []
// [K2 S164] Nhóm đã có kế hoạch SỐNG trên PE đang chọn — nguồn DUY NHẤT là DTO picker
// (`existingGroups`), FE không tự suy từ đâu khác. `?? []` giữ trang sống với payload
// cũ còn nằm trong cache React Query trước khi K2 deploy.
const selectedPe = list.find((p) => p.id === purchaseEvaluationId) const selectedPe = list.find((p) => p.id === purchaseEvaluationId)
const takenGroups = selectedPe?.existingGroups ?? [] const takenGroups = selectedPe?.existingGroups ?? []
const groupTaken = takenGroups.includes(approvalGroup) const groupTaken = takenGroups.includes(approvalGroup)
// [S174] Khuôn `PeWorkspaceCreateView.tsx:145-150`: liệt kê ĐÍCH DANH cái còn thiếu thay
// vì chỉ khoá nút — nút xám không nói được vì sao nó xám.
const missingCreate = [ const missingCreate = [
!purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn', !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
!approvalWorkflowId && 'Quy trình duyệt', !approvalWorkflowId && 'Quy trình duyệt',
@ -158,221 +146,259 @@ export function KhkkCreatePage() {
].filter(Boolean) as string[] ].filter(Boolean) as string[]
const canSubmit = missingCreate.length === 0 && !create.isPending const canSubmit = missingCreate.length === 0 && !create.isPending
// Bấm chọn PE (cả ở dòng lẫn ở nút "Tạo thêm cho nhóm khác"): nếu nhóm đang chọn đã // Bấm PE ở panel 1: nạp vào form + rời chế-độ xem. Nhóm đang chọn đã bị PE đó dùng thì
// bị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò. // nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
const selectPe = (pe: KhkkPeCandidateDto) => { const pickPe = (peId: string) => {
setPurchaseEvaluationId(pe.id) setPurchaseEvaluationId(peId)
const taken = pe.existingGroups ?? [] setViewPlanId(null)
const taken = list.find((p) => p.id === peId)?.existingGroups ?? []
if (taken.includes(approvalGroup)) { if (taken.includes(approvalGroup)) {
const free = firstFreeGroup(taken) const free = firstFreeGroup(taken)
if (free !== null) setApprovalGroup(free) if (free !== null) setApprovalGroup(free)
} }
} }
return ( const onSubmit = (e: FormEvent) => {
<form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm"> e.preventDefault()
{/* Thanh đầu — mirror `PeWorkspaceCreateView.tsx:155-165` */} create.mutate()
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3"> }
<div>
<h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
<p className="mt-0.5 text-[12px] text-slate-500">
Nhập Section 1 + 2 bấm <strong>&ldquo;Tạo kế hoạch&rdquo;</strong> sau đó dòng giá NCC / căn cứ hồ / ý kiến mở khóa.
</p>
</div>
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
</div>
<div className="divide-y divide-slate-200"> const headerTitle = presetGroup !== null
<Section title="1. Phiếu Duyệt NCC nguồn *"> ? `Kế hoạch ký kết HĐ — Thao tác · ${khkkGroupMenuLabel(presetGroup)}`
{candidates.isLoading && <div className="text-sm text-muted-foreground">Đang tải danh sách phiếu...</div>} : 'Kế hoạch ký kết HĐ — Thao tác'
{!candidates.isLoading && list.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-muted-foreground"> return (
Không phiếu Duyệt NCC nào đã duyệt NCC trúng thầu. <div className="flex h-[calc(100vh-4rem)] flex-col">
<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 min-w-0 items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" />
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
</div>
<div className="text-[12px] text-slate-500">
Bấm phiếu cột trái tầng trên lập kế hoạch, tầng dưới chỉ xem.
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]">
<KhkkWorkListPanel
onPickPe={pickPe}
onPickPlan={setViewPlanId}
selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId}
footer={
<Button
type="button"
className="w-full"
// "Thêm mới" = trả panel 2 về form TRẮNG. Không điều hướng đi đâu.
onClick={() => {
setViewPlanId(null)
setPurchaseEvaluationId('')
setApprovalWorkflowId('')
setGhiChu('')
}}
>
<Plus className="mr-2 h-4 w-4" />
Thêm mới
</Button>
}
/>
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
{viewPlanId && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2">
<span className="text-[12px] font-medium text-violet-800">
Đang xem kế hoạch chỉ xem, không sửa màn Thao tác.
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
Mở trang đy đ
</Button>
<Button variant="ghost" size="sm" onClick={() => setViewPlanId(null)}>
<X className="mr-1.5 h-3.5 w-3.5" />
Về form tạo
</Button>
</div>
</div>
{viewPlan.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{!viewPlan.isLoading && !viewPlan.data && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)}
{viewPlan.data && (
<KhkkDetailContent plan={viewPlan.data} readOnly onChanged={() => viewPlan.refetch()} />
)}
</div> </div>
)} )}
<div className="max-h-96 space-y-2 overflow-y-auto">
{list.map((pe) => {
const selected = pe.id === purchaseEvaluationId
// [K2 S164 — OG-7] `?? []` = phòng payload cũ trong cache (trước K2 DTO
// không có field này) — thiếu guard thì `.length` nổ trắng trang.
const taken = pe.existingGroups ?? []
const free = firstFreeGroup(taken)
return (
<div key={pe.id} className="space-y-1">
<button
type="button"
onClick={() => selectPe(pe)}
className={cn(
'flex w-full items-start gap-3 rounded-lg border p-3 text-left transition',
selected
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-300'
: 'border-slate-200 hover:border-brand-300 hover:bg-slate-50',
)}
>
<CheckCircle2
className={cn('mt-0.5 h-4 w-4 shrink-0', selected ? 'text-brand-600' : 'text-slate-300')}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="font-medium text-brand-800">{pe.tenGoiThau}</span>
</span>
<span className="mt-0.5 block text-xs text-slate-500">
{pe.projectName ?? '—'}
{pe.workItemName ? ` · ${pe.workItemName}` : ''} · {formatDate(pe.createdAt)}
</span>
<span className="mt-1 flex flex-wrap gap-1">
{pe.winnerSupplierNames.length === 0 ? (
<span className="text-xs italic text-slate-400">Chưa NCC trúng thầu</span>
) : (
pe.winnerSupplierNames.map((n) => (
<span
key={n}
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
>
{n}
</span>
))
)}
</span>
</span>
</button>
{/* [K2 S164 — OG-7] PE đã có kế hoạch: KHÔNG ẩn dòng nữa, hiện nhóm {/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
đã dùng + lối đi tiếp. Nút nằm NGOÀI <button> ở trên (button lồng {!viewPlanId && !purchaseEvaluationId && (
button là HTML không hợp lệ). */} <EmptyState
{taken.length > 0 && ( icon={FileCheck}
<div className="flex flex-wrap items-center gap-2 pl-8 text-xs"> title="Chọn phiếu Duyệt NCC để lập kế hoạch"
<span className="text-slate-500">Đã kế hoạch nhóm:</span> description="Bấm một phiếu ở tầng trên cột trái để lập kế hoạch ký kết, hoặc bấm một kế hoạch ở tầng dưới để xem."
{taken.map((g) => ( />
<span )}
key={g}
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800" {/* (c) Đã chọn PE — form tạo. */}
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`} {!viewPlanId && purchaseEvaluationId && (
> <form onSubmit={onSubmit} className="rounded-lg border border-slate-200 bg-white shadow-sm">
N{g} <div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
</span> <div>
))} <h2 className="text-base font-semibold text-slate-900">Lập kế hoạch kết mới</h2>
{free === null ? ( <p className="mt-0.5 text-[12px] text-slate-500">
<span className="italic text-slate-400">Đã dùng hết 8 nhóm duyệt</span> Kiểm Section 1 + 2 bấm <strong>&ldquo;Tạo kế hoạch&rdquo;</strong> sau đó dòng giá NCC / căn cứ hồ / ý kiến mở khóa.
</p>
</div>
</div>
<div className="divide-y divide-slate-200">
<Section title="1. Phiếu Duyệt NCC nguồn *">
{/* 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). */}
<div className="rounded-lg border border-brand-300 bg-brand-50/60 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-brand-700">{selectedPe?.maPhieu ?? '—'}</span>
<span className="font-medium text-brand-800">{selectedPe?.tenGoiThau ?? '(đang tải…)'}</span>
</div>
<div className="mt-0.5 text-xs text-slate-500">
{selectedPe?.projectName ?? '—'}
{selectedPe?.workItemName ? ` · ${selectedPe.workItemName}` : ''}
</div>
{(selectedPe?.winnerSupplierNames?.length ?? 0) > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{selectedPe!.winnerSupplierNames.map((n) => (
<span
key={n}
className="inline-flex items-center rounded-md border border-emerald-300 bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800"
>
{n}
</span>
))}
</div>
)}
{takenGroups.length > 0 && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-slate-500">Đã kế hoạch nhóm:</span>
{takenGroups.map((g) => (
<span
key={g}
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-800"
title={KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
>
N{g}
</span>
))}
</div>
)}
</div>
</Section>
<Section title="2. Nhóm duyệt & quy trình">
<div className="grid gap-3 md:grid-cols-2">
<div>
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label>
<Select
id="approvalGroup"
value={approvalGroup}
onChange={(e) => setApprovalGroup(Number(e.target.value))}
>
{KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g)
return (
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu này đã kế hoạch cho nhóm N{approvalGroup} chọn nhóm khác.
</p>
) : ( ) : (
<Button type="button" variant="outline" size="sm" onClick={() => selectPe(pe)}> <p className="mt-1 text-[11px] text-slate-500">
<Plus className="mr-1.5 h-3.5 w-3.5" /> Mỗi phiếu Duyệt NCC lập đưc 1 kế hoạch cho MỖI nhóm duyệt
Tạo thêm cho nhóm khác </p>
</Button>
)} )}
</div> </div>
<div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
<Select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
>
<option value=""> Chọn quy trình duyệt </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>{w.code} - {w.name}</option>
))}
</Select>
{workflows.data && workflows.data.length === 0 ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình admin đã ghim cho user chọn
</p>
)}
</div>
</div>
</Section>
<Section title="3. Ghi chú">
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
</Section>
</div>
{/* Hint TRÁI kể rõ còn thiếu gì, nút PHẢI. `groupTaken` chặn ở FE lượt bấm chắc
chắn ăn 409 của BE (`Features.cs:395-402`) — BE vẫn là rào thật. */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="min-w-0 text-[11px]">
{missingCreate.length > 0 ? (
<span className="text-red-600">
Chưa đ đ tạo kế hoạch còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
</span>
) : (
<span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
)} )}
</div> </div>
) <div className="flex shrink-0 items-center gap-2">
})} <Button
</div> type="button"
</Section> variant="ghost"
className="text-xs"
<Section title="2. Nhóm duyệt & quy trình"> onClick={() => { setPurchaseEvaluationId(''); setApprovalWorkflowId(''); setGhiChu('') }}
<div className="grid gap-3 md:grid-cols-2"> >
{/* [K2 S164 — vá-4] Nhóm duyệt N1..N8. Mặc định nhóm 1 (UI-hướng-1: ca HẸP, Hủy
user cần nhóm khác thì đổi ngay tại đây). Nhóm PE đã dùng bị disable — </Button>
rào cứng 409 vẫn ở BE, ô này chỉ để không mời bấm nhầm. */} <Button type="submit" disabled={!canSubmit}>
<div> {create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
<Label htmlFor="approvalGroup" className="text-[11px]">Nhóm duyệt (N1N8) *</Label> </Button>
<Select </div>
id="approvalGroup" </div>
value={approvalGroup} </form>
onChange={(e) => setApprovalGroup(Number(e.target.value))}
>
{KHKK_APPROVAL_GROUPS.map((g) => {
const used = takenGroups.includes(g)
return (
<option key={g} value={g} disabled={used}>
N{g} - {KHKK_APPROVAL_GROUP_LABELS[g] ?? `Nhóm ${g}`}
{used ? ' (đã có kế hoạch)' : ''}
</option>
)
})}
</Select>
{groupTaken ? (
<p className="mt-1 text-[11px] font-medium text-red-600">
Phiếu Duyệt NCC này đã kế hoạch kết cho nhóm N{approvalGroup} chọn nhóm khác.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Mỗi phiếu Duyệt NCC lập đưc 1 kế hoạch cho MỖI nhóm duyệt
</p>
)}
</div>
<div>
<Label htmlFor="workflow" className="text-[11px]">Quy trình duyệt *</Label>
<Select
id="workflow"
value={approvalWorkflowId}
onChange={(e) => setApprovalWorkflowId(e.target.value)}
>
<option value=""> Chọn quy trình duyệt </option>
{workflows.data?.map((w) => (
<option key={w.id} value={w.id}>
{w.code} - {w.name}
</option>
))}
</Select>
{workflows.data && workflows.data.length === 0 ? (
<p className="mt-1 text-[11px] text-amber-700">
Chưa quy trình duyệt "Kế hoạch ký kết HĐ" nào đưc ghim cho user chọn. Liên hệ admin tạo trước.
</p>
) : (
<p className="mt-1 text-[11px] text-slate-500">
Chỉ hiển thị quy trình loại "Kế hoạch ký kết HĐ" admin đã ghim cho user chọn
</p>
)}
</div>
</div>
</Section>
<Section title="3. Ghi chú">
<Label htmlFor="ghiChu" className="text-[11px]">Ghi chú kế hoạch</Label>
<Textarea
id="ghiChu"
value={ghiChu}
onChange={(e) => setGhiChu(e.target.value)}
maxLength={2000}
rows={4}
placeholder="Lưu ý về tiến độ ký kết, điều kiện thương thảo..."
/>
<div className="mt-1 text-[11px] text-slate-500">{ghiChu.length}/2000</div>
</Section>
</div>
{/* Thanh cuối — hint TRÁI kể rõ còn thiếu gì, nút PHẢI (mirror `:318-337`).
`groupTaken` chặn ngay ở FE lượt bấm chắc chắn ăn 409 của BE
(`Features.cs:395-402`) — vẫn để BE là rào thật, đây chỉ đỡ 1 vòng mạng. */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-5 py-3">
<div className="min-w-0 text-[11px]">
{missingCreate.length > 0 ? (
<span className="text-red-600">
Chưa đ đ tạo kế hoạch còn thiếu: <strong>{missingCreate.join(' · ')}</strong>
</span>
) : (
<span className="font-medium text-emerald-600"> Đ thông tin bắt buộc thể tạo kế hoạch</span>
)} )}
</div> </main>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" variant="ghost" onClick={() => navigate('/khkk/list')} className="text-xs">
Hủy
</Button>
<Button type="submit" disabled={!canSubmit}>
{create.isPending ? 'Đang tạo…' : 'Tạo kế hoạch'}
</Button>
</div>
</div> </div>
</form> </div>
) )
} }
// Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do // Helper — duplicate từ `PeWorkspaceCreateView` để tránh circular import (cùng lý do vì sao
// vì sao bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`). // bên đó cũng tự dựng lại thay vì import từ `PeDetailTabs`).
function Section({ title, children }: { title: string; children: ReactNode }) { function Section({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<section className="px-5 py-4"> <section className="px-5 py-4">

View File

@ -15,7 +15,6 @@ import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react' import { ClipboardCheck } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeKhkkSidePanel } from '@/components/pe/PeKhkkSidePanel'
import { PeListPanel } from '@/components/pe/PeListPanel' import { PeListPanel } from '@/components/pe/PeListPanel'
import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView' import { PeWorkspaceCreateView } from '@/components/pe/PeWorkspaceCreateView'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -78,10 +77,7 @@ export function PurchaseEvaluationWorkspacePage() {
</div> </div>
</header> </header>
{/* [S174 owner] Cột 3 (`PeKhkkSidePanel`, 2 tầng 50/50) CHỈ mọc ở `xl` — thêm nó vào <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]">
`lg` sẽ bóp panel giữa (nơi nhập liệu thật) xuống dưới ngưỡng dùng được. Ở `lg`
bố cục giữ ĐÚNG 2 panel như trước, không đổi một pixel nào. */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr_320px]">
{/* Panel 1: List pure picker + sticky create + pencil edit hover. {/* Panel 1: List pure picker + sticky create + pencil edit hover.
Workspace chỉ list phiếu Bản nháp (DangSoanThao) — đã gửi duyệt rồi Workspace chỉ list phiếu Bản nháp (DangSoanThao) — đã gửi duyệt rồi
không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */} không hiện ở đây (vào Danh sách / Duyệt). User 2026-05-07. */}
@ -134,10 +130,6 @@ export function PurchaseEvaluationWorkspacePage() {
/> />
)} )}
</main> </main>
{/* Panel 3: 1 cột 2 tầng — trên = PE đã duyệt CHƯA lập KHKK (gom theo dự án),
dưới = KHKK đã tạo. Tự gate `KeHoachKyKet.Read` bên trong (gotcha #85). */}
<PeKhkkSidePanel />
</div> </div>
{/* Mobile fallback: nếu không lg, redirect về detail page */} {/* Mobile fallback: nếu không lg, redirect về detail page */}