+ {/* Không dùng `signed!` — thu hẹp bằng chính `signed &&` để
+ khỏi mượn null-forgiving trên dữ-liệu từ API (gotcha #80). */}
+ {signed && signed.approverUserId
+ && signed.approverUserId !== signed.signedByUserId && (
+
+ {signed.comment?.trim() || '(duyệt — không ý kiến)'}
+
+
+ {formatDateTime(signed.signedAt)}
+
+ >
+ ) : (
+
Chưa có ý kiến duyệt.
+ )}
+
+ )
+ })}
+
+ )}
)
diff --git a/fe-admin/src/components/khkk/KhkkListPanel.tsx b/fe-admin/src/components/khkk/KhkkListPanel.tsx
index 3f52452..a01cdb7 100644
--- a/fe-admin/src/components/khkk/KhkkListPanel.tsx
+++ b/fe-admin/src/components/khkk/KhkkListPanel.tsx
@@ -31,11 +31,9 @@ import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
- KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
- khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
@@ -172,7 +170,6 @@ export function KhkkListPanel({
phase,
onPhaseChange,
group,
- onGroupChange,
deletedView,
page,
onPageChange,
@@ -184,8 +181,10 @@ export function KhkkListPanel({
onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void
+ // [S174 owner] CHỈ còn đọc — `Select` nhóm đã gỡ khỏi UI. Nhóm nay đến DUY NHẤT từ URL
+ // (`?group=n`, tức 8 leaf nhóm ở menu trái). Máy lọc giữ NGUYÊN (query + `stage2Content`);
+ // chỉ bỏ đường đổi nhóm bằng tay — vào từ leaf nhóm rồi thì ô đó là thừa.
group: number | null
- onGroupChange: (v: number | null) => void
deletedView: boolean
page: number
onPageChange: (p: number) => void
@@ -265,28 +264,16 @@ export function KhkkListPanel({
className="pl-8"
/>
-
-
-
-
+
{flatMode && !deletedView && canPe && (
Đang lọc — danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây).
@@ -305,6 +292,7 @@ export function KhkkListPanel({
diff --git a/fe-admin/src/components/pe/PeKhkkSidePanel.tsx b/fe-admin/src/components/pe/PeKhkkSidePanel.tsx
new file mode 100644
index 0000000..8952a81
--- /dev/null
+++ b/fe-admin/src/components/pe/PeKhkkSidePanel.tsx
@@ -0,0 +1,252 @@
+// [S174 owner] Cột PHẢI của màn "Duyệt NCC — Thao tác" — 1 cột, 2 TẦNG 50/50:
+// · 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";
+// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — đây là "việc đã làm rồi".
+// 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
+// là KHKK". File MIRROR SHA256 identical với fe-user counterpart.
+//
+// 🔴 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 { useNavigate } from 'react-router-dom'
+import { FileCheck, FileSignature, Lock } from 'lucide-react'
+import { usePermission } from '@/hooks/usePermission'
+import { api } from '@/lib/api'
+import { cn } from '@/lib/cn'
+import {
+ KHKK_PHASE_BADGE,
+ KHKK_PHASE_LABELS,
+ 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 (
+
+ )
+}
+
+export function PeKhkkSidePanel() {
+ const navigate = useNavigate()
+ 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)
+
+ // Gom theo DỰ ÁN — đúng chữ "theo dự án" của anh. `Map` giữ thứ tự chèn nên dự án nào có
+ // phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trước.
+ const byProject = new Map()
+ for (const pe of awaiting) {
+ const key = pe.projectName ?? '(chưa gán dự án)'
+ const bucket = byProject.get(key)
+ if (bucket) bucket.push(pe)
+ else byProject.set(key, [pe])
+ }
+
+ const planItems = plans.data?.items ?? []
+
+ return (
+
+ )
+}
diff --git a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx
index 3714bb9..f387a11 100644
--- a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx
+++ b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx
@@ -88,6 +88,7 @@ export function PipelineTreePanel({
className,
bodyMaxHClass,
stage2Options,
+ hideHeader,
}: {
currentStage: PipelineStageNo
className?: string
@@ -100,6 +101,12 @@ export function PipelineTreePanel({
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
// Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi.
bodyMaxHClass?: string
+ // [S174 owner] Ẩn thanh tiêu-đề "Cây toàn trình · N gói". CHỈ dùng ở host đã có tiêu-đề
+ // riêng ngay trên (KHKK GĐ2: header trang `KhkkListPage.tsx:124`) — 2 tiêu-đề chồng nhau
+ // trong cùng một cột là thừa. 6 host còn lại KHÔNG truyền ⇒ giữ nguyên hình cũ.
+ // 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi
+ // `
-
-
-
-
- Cây toàn trình
-
- {packageCount} gói
-
-
-
+ {!hideHeader && (
+
+
+
+
+ Cây toàn trình
+
+ {packageCount} gói
+
+
+
+ )}
-
+ {/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `
{
@@ -121,6 +127,7 @@ export function KhkkCreatePage() {
const res = await api.post<{ id: string; maKeHoach: string | null }>('/contract-signing-plans', body)
return res.data
},
+ // 🔴 Vế "Header tạo xong thì sang chi tiết" nằm ĐÚNG ở đây — giữ nguyên từ S161.
onSuccess: (data) => {
toast.success(data.maKeHoach ? `Đã lập kế hoạch ${data.maKeHoach}` : 'Đã lập kế hoạch ký kết')
navigate(`/khkk/${data.id}`)
@@ -142,6 +149,15 @@ export function KhkkCreatePage() {
const takenGroups = selectedPe?.existingGroups ?? []
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 = [
+ !purchaseEvaluationId && 'Phiếu Duyệt NCC nguồn',
+ !approvalWorkflowId && 'Quy trình duyệt',
+ groupTaken && `Nhóm duyệt còn trống (N${approvalGroup} đã có kế hoạch)`,
+ ].filter(Boolean) as string[]
+ 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ị PE đó dùng thì nhảy sang nhóm trống đầu tiên ⇒ người dùng không phải tự dò.
const selectPe = (pe: KhkkPeCandidateDto) => {
@@ -154,52 +170,37 @@ export function KhkkCreatePage() {
}
return (
-