[CLAUDE] Contract: K4a+K4b SP-002 sidebar 8 nhom x 6 leaf + noi ?group= end-to-end (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m23s

K4a (gate PASS-WITH-FLAGS 4, sub-reviewer-gate-k4a.md): 49 key Khkk_G{2-8} bo-sinh
by-construction 3-phia (All/seed/grant) MenuKeys.cs + DbInitializer 49 row + labelBackfill
G1 nhan A1 + site-2 Concat + va 7/10/11/13/15 (nhan N2 co "pha do"; comment 1893 -> mo-neo
grep). All 64->113, Policies 256->452 (test T2 khoa runtime). Test +7 KhkkGroupMenuSeedTests
(T7 revoker-khong-dung-Khkk: probe vi-phan 112->0, gotcha #84).

K4b (gate PASS-WITH-FLAGS 4M/5m 0-chan, sub-reviewer-gate-k4b.md): KhkkListPage x2 derive
phase+group tu searchParams (leaf->leaf khong F5, navKey reset) + queryKey {group,phase,
search,page,deletedView} + KhkkCreatePage preset ?group= + staticMap G1 6-leaf group=1 +
regex Khkk_G([1-8])_* fe-user (fe-admin GHI RANG BUOC isAdminHidden) + FLAG-1 lead-fix:
GET /deleted +group mirror List (Controller+Query+handler SAU-IDOR) + FE noi 2-nhanh.

2 wave 1 commit CO KHAI: Layout.tsx dinh ca 2 wave khong tach hunk phi-tuong-tac; moi dong
deu qua gate rieng. STATUS canonical 5-row dong bo (71/97/452/113/620->621) + bundle :479.
No K4c: MAJOR-A test /deleted+group filter + MAJOR-C WfView doc group. Suite 621/0
(45D+576I) + filter 41/41 + npm x2 + dotnet 0E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-01 10:10:11 +07:00
parent bc1e7c937d
commit 88e7ecf263
14 changed files with 880 additions and 63 deletions

View File

@ -56,10 +56,14 @@ function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
// [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50:
// thiếu route = sidebar drop SILENT; filter đi qua query param, page map FILTER_TO_PHASE).
Khkk_List: '/khkk/list',
Khkk_Create: '/khkk/create',
Khkk_Pending: '/khkk/list?filter=ChoDuyet',
Khkk_Approved: '/khkk/list?filter=DaDuyet',
// [K4b S166 — spec K4b-7] 6 leaf nhóm 1 mang `group=1` TƯỜNG MINH. Chúng là 6 key
// cũ KHÔNG-infix (S159, cấm đổi tên) nên không đi qua regex `Khkk_G{n}_*` bên dưới;
// thiếu `group=1` ở đây thì "Danh sách" của nhóm 1 lại hiện phiếu của cả 8 nhóm —
// đúng một mục nói dối giữa 8 mục nói thật. Trang coi "vắng group" = xem tất cả.
Khkk_List: '/khkk/list?group=1',
Khkk_Create: '/khkk/create?group=1',
Khkk_Pending: '/khkk/list?group=1&filter=ChoDuyet',
Khkk_Approved: '/khkk/list?group=1&filter=DaDuyet',
// [W7 KHKK S161] GĐ4 hết skeleton — 7 leaf `Hdc_*` re-point trang thật
// `/hard-copies?type=N` (N = ContractType int, mirror TYPE_CODE_TO_INT ở trên).
// gotcha #50: thiếu/để sai route ở đây = leaf DROP IM LẶNG khỏi sidebar.
@ -79,8 +83,8 @@ function resolvePath(key: string): string | null {
KeHoachKyKet: '/khkk/list', // [W2 S161] root GĐ2 hết placeholder — về danh sách phiếu
HopDongCung: '/dashboard',
// [W2 S161] 2 leaf còn lại — WfView đi alias matrix type=10 (App.tsx, không đẻ page mới)
Khkk_WfView: '/khkk/workflow-matrix?type=10',
Khkk_Deleted: '/khkk/list?view=deleted',
Khkk_WfView: '/khkk/workflow-matrix?type=10&group=1',
Khkk_Deleted: '/khkk/list?group=1&view=deleted',
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
@ -181,6 +185,41 @@ function resolvePath(key: string): string | null {
if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7&view=approved`
if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1`
}
// ===================== [K4a — S166] Sidebar KHKK 8 NHÓM DUYỆT ======================
// `Khkk_G{n}_{action}` — 42 leaf của nhóm 2..8 (BE `MenuKeys.KhkkLeaf` :77, sinh từ
// `KhkkGroupNumbers = [2..8]` × `KhkkLeafActions` 6 hành động). Đây là "chỗ thứ 4"
// Pattern 16-bis: BE seed 42 row mà thiếu nhánh này thì `resolvePath` trả null ⇒
// MenuLeaf nhánh `if (!path) return null` (grep chuỗi đó — số dòng trượt theo edit) ⇒ 42 leaf DROP IM LẶNG (gotcha #50).
//
// 🔴 NHÓM 1 KHÔNG đi qua đây: 7 key cũ KHÔNG-infix (`Khkk_G1` + `Khkk_WfView` …
// `Khkk_Deleted`, seed S159) nằm trong `staticMap` bên trên và CẤM đổi tên
// (đổi = mồ côi permission-row đã cấp — bài S155). Vì thế regex dưới về mặt dữ
// liệu chỉ ăn n = 2..8; để dải `[1-8]` theo spec là CỐ Ý — rộng hơn tập seed một
// nấc, vô hại hôm nay và đúng sẵn nếu sau này nhóm 1 chuyển sang khuôn infix.
//
// 🔴 `?group=n` là DANH TÍNH điều hướng, không phải bộ lọc tuỳ hứng: nó KHÔNG nằm
// trong `TRANSIENT_QUERY_KEYS` (grep tên hằng — đừng neo số dòng) nên 6 leaf cùng nhóm và cùng leaf khác nhóm
// đều phân biệt được khi so khớp active (bài `view=approved` S155 ngay khối trên).
// Tên tham số `filter` / `view` / `group` lấy ĐÚNG từ `KhkkListPage` (grep
// `get('filter')` / `get('view')` / `get('group')` trong file đó — đừng neo số dòng,
// chính wave K4b đã làm số cũ trượt) — sai một chữ ở đây là leaf trỏ tới trang mở
// được nhưng lọc SAI, hỏng câm chứ không đỏ build.
//
// 🔴 fe-admin CỐ Ý KHÔNG nới regex này (lead chốt 2026-07-27, giữ lệch): fe-admin
// không có `WorkflowMatrixViewPage` (`App.tsx` bên đó chỉ có /khkk/list|create|:id)
// nên `WfView` bên admin sẽ là link chết; "Luồng duyệt" phía admin trỏ Designer.
// Khai lệch có chủ đích — xem ghi chú ràng buộc ngược tại `isAdminHidden` fe-admin.
const khkkMatch = key.match(/^Khkk_G([1-8])_(WfView|List|Create|Pending|Approved|Deleted)$/)
if (khkkMatch) {
const [, group, action] = khkkMatch
if (action === 'WfView') return `/khkk/workflow-matrix?type=10&group=${group}`
if (action === 'List') return `/khkk/list?group=${group}`
if (action === 'Create') return `/khkk/create?group=${group}`
if (action === 'Pending') return `/khkk/list?group=${group}&filter=ChoDuyet`
if (action === 'Approved') return `/khkk/list?group=${group}&filter=DaDuyet`
if (action === 'Deleted') return `/khkk/list?group=${group}&view=deleted`
}
return null
}

View File

@ -14,7 +14,7 @@
// không phân biệt được "PE không đủ điều kiện" với "PE đã dùng hết nhóm".
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { CheckCircle2, FileCheck, FileText, Layers, Plus, Save, Settings2, X } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/ui/PageHeader'
@ -57,11 +57,31 @@ function firstFreeGroup(taken: number[]): number | null {
export function KhkkCreatePage() {
const navigate = useNavigate()
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 presetGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const [purchaseEvaluationId, setPurchaseEvaluationId] = useState('')
const [approvalWorkflowId, setApprovalWorkflowId] = useState('')
const [approvalGroup, setApprovalGroup] = useState<number>(KHKK_APPROVAL_GROUP_DEFAULT)
const [approvalGroup, setApprovalGroup] = useState<number>(presetGroup ?? KHKK_APPROVAL_GROUP_DEFAULT)
const [ghiChu, setGhiChu] = useState('')
// Cùng lý do với `KhkkListPage` (vá-5): 8 leaf "Thao tác" dùng chung route
// `/khkk/create`, nhảy leaf→leaf không dựng lại component nên giá trị khởi tạo ở
// 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)
if (presetGroup !== lastPresetGroup) {
setLastPresetGroup(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
// ĐÚ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`).

View File

@ -3,13 +3,20 @@
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
// fe-admin counterpart.
//
// URL vào từ menu (Layout.tsx staticMap — chỗ thứ 4 của Pattern 16-bis):
// Khkk_List → /khkk/list
// Khkk_Pending → /khkk/list?filter=ChoDuyet
// Khkk_Approved → /khkk/list?filter=DaDuyet
// Khkk_Deleted → /khkk/list?view=deleted
// URL vào từ menu (Layout.tsx staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm
// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
// Khkk_List / Khkk_G{n}_List → /khkk/list?group={n}
// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
// Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
// 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng
// nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc).
//
// [K4b S166] 🔴 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ông chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5).
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
@ -29,6 +36,7 @@ import {
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
@ -58,17 +66,50 @@ function formatDate(iso: string): string {
export function KhkkListPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const initialFilter = searchParams.get('filter')
const deletedView = searchParams.get('view') === 'deleted'
const [phase, setPhase] = useState<KhkkPhaseValue | null>(
initialFilter ? (FILTER_TO_PHASE[initialFilter] ?? null) : null,
)
// ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ----
// Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi
// query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại.
// Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5).
const urlFilter = searchParams.get('filter')
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
const deletedView = searchParams.get('view') === 'deleted'
// `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1,
// vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh).
const groupParam = searchParams.get('group')
const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
// ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL;
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
// Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đứng trang 5 của nhóm 2
// mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
// vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm
// một mã rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
const [lastNavKey, setLastNavKey] = useState(navKey)
if (navKey !== lastNavKey) {
setLastNavKey(navKey)
setChipPhase(undefined)
setPage(1)
}
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
const list = useQuery({
queryKey: ['khkk-list', { phase, search, page, deletedView }],
// 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
// 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
// có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là
// `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server)
// ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô
// cache cho hai request y hệt nhau.
queryKey: ['khkk-list', { group, phase, search, page, deletedView }],
queryFn: async () => {
// Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng
// `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155).
@ -76,6 +117,11 @@ export function KhkkListPage() {
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: {
phase: deletedView ? undefined : (phase ?? undefined),
// [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror
// List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery`
// + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server,
// total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa.
group: group ?? undefined,
search: search.trim() || undefined,
page,
pageSize: PAGE_SIZE,
@ -115,17 +161,29 @@ export function KhkkListPage() {
<div className="space-y-5">
<PageHeader
eyebrow="Giai đoạn 2"
title={deletedView ? 'Kế hoạch ký kết HĐ — đã xóa' : 'Kế hoạch ký kết HĐ'}
subtitle={
title={
deletedView
? 'Phiếu đã xóa mềm — chỉ xem danh sách'
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null
? `Kế hoạch ký kết HĐ — N${group}`
: 'Kế hoạch ký kết HĐ'
}
subtitle={
// [K4b S166] Màn "Đã xóa" nay ĐƯỢC in tên nhóm — `/deleted` đã lọc server-side
// theo `group` (FLAG-1 nối cùng lượt), tên nhóm hết là lời nói dối.
deletedView
? (group !== null ? `${khkkGroupMenuLabel(group)} — phiếu đã xóa mềm, chỉ xem` : 'Phiếu đã xóa mềm — chỉ xem danh sách')
: group !== null
? khkkGroupMenuLabel(group)
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
}
icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />}
accent="brand"
actions={
deletedView ? undefined : (
<Button onClick={() => navigate('/khkk/create')}>
// Mang nhóm đang xem sang màn lập phiếu — không bắt người dùng chọn lại
// đúng cái nhóm họ vừa đứng (bắt tay với vá-6 bên `KhkkCreatePage`).
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
<Plus className="mr-2 h-4 w-4" />
Lập kế hoạch
</Button>
@ -151,7 +209,9 @@ export function KhkkListPage() {
accent={c.accent}
active={phase === c.value}
onClick={() => {
setPhase(c.value)
// Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng
// để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file).
setChipPhase(c.value)
setPage(1)
}}
/>

View File

@ -126,6 +126,12 @@ export const KHKK_APPROVAL_GROUPS = [1, 2, 3, 4, 5, 6, 7, 8] as const
// đề xuất", **[OG-6] anh soát SAU trên UI**). Cùng bộ chữ này K4a sẽ seed làm LABEL menu
// ⇒ nếu K4a land nhãn khác thì SỬA Ở ĐÂY (1 const, 2 app), đừng vá rải trong page.
// Rút gọn có chủ đích: bỏ số thứ tự đầu dòng của bảng (ô select đã có "N{n}").
//
// [K4a S166 — acceptance-5 single-source] ĐÃ ĐỐI CHIẾU TỪNG KÝ TỰ với nhãn BE seed
// (`DbInitializer.cs:1960` cho nhóm 1 + dict `khkkGroupLabels` `:2127-2136` cho nhóm
// 2..8, cùng `labelBackfill:2235`): 8/8 nhóm khớp theo quan hệ `"{n}. " + chuỗi dưới đây`.
// ⇒ K4a KHÔNG đẻ map thứ hai. Bản đầy đủ (có số thứ tự) lấy qua `khkkGroupMenuLabel(n)`
// ngay bên dưới — DERIVE chứ không chép, để sửa chữ vẫn chỉ đụng đúng 1 chỗ này.
export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
1: 'Thiết bị, máy móc, vật tư phụ (A1)',
2: 'Kết cấu, hạ tầng, phá dỡ & DV hỗ trợ thi công (A2-A3)',
@ -137,6 +143,25 @@ export const KHKK_APPROVAL_GROUP_LABELS: Record<number, string> = {
8: 'HĐ chưa ủy quyền — CEO (5.2)',
}
// [K4a S166] Nhãn nhóm ĐẦY ĐỦ = đúng chuỗi BE ghi vào `MenuItems.Label` khi seed.
// 🔴 ĐÂY KHÔNG PHẢI NGUỒN CỦA SIDEBAR. Sidebar render nhãn từ payload `/api/menus/me`
// (bảng `MenuItems`, admin còn đổi được qua `displayLabel` — Mig 27), nên đừng ai
// tưởng sửa chuỗi ở đây thì sidebar đổi theo. Hàm này phục vụ chỗ FE tự vẽ tên nhóm
// mà không đi qua menu: cây 8 thư mục nhóm (K5), tiêu đề trang khi vào từ leaf nhóm,
// màn xem luồng duyệt theo nhóm (WfView).
// ⚠️ Ràng buộc ngầm: BE đang đặt nhãn theo khuôn "{n}. {tên}". Nếu BE bỏ tiền tố số thì
// hàm này lệch một cách CÂM (không đỏ build) — lúc đó sửa chính hàm này, đừng vá page.
export function khkkGroupMenuLabel(n: number): string {
const label = KHKK_APPROVAL_GROUP_LABELS[n]
return label ? `${n}. ${label}` : `Nhóm ${n}`
}
// [K4a S166 — O-2] Mã quy trình duyệt của nhóm n. BE khớp bằng regex `^KHKK-N([1-8])$`
// (`ContractSigningPlanFeatures.cs:420`, rào 409 "phiếu nhóm 3 pin quy trình nhóm 5");
// 8 workflow do `DbInitializer.SeedKhkkGroupWorkflowsAsync` seed, ApplicableType=10.
// Gom về 1 hàm để FE không rải chuỗi `KHKK-N` ở nhiều nơi rồi lệch nhau.
export const khkkGroupWorkflowCode = (n: number) => `KHKK-N${n}`
export interface KhkkListItemDto {
id: string
maKeHoach: string | null