[CLAUDE] Contract: KHKK UAT vong-4 — 3 menu loc hien cay Nam>Du an>Hang muc>phieu (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m39s

Owner: "o cac menu dang duyet/ da duyet/ xoa -> cho hien thi nam -> du an
-> hang muc -> phieu KHKK nhe". Day chinh la O5 "can phan" ton tu S174 —
nghi van `KhkkListPanel flatMode` doi CAY->PHANG khi co filter. Owner phan:
giu CAY.

PipelineTreePanel — them `leafMode` OPT-IN:
- Cay dung o tang Hang muc roi ve THANG phieu: bo tang folder giai-doan VA
  bo tang 8 ngan nhom => dung 4 tang owner go.
- Panel KHONG import kieu KHKK: host cap ca phep DEM lan phep VE, nen cay
  dung lai duoc cho giai-doan khac sau nay.
- `buildTree(rows, weightOf)` — badge dem PHIEU thay vi GOI o ca 3 tang.
  Giu `+1` thi 1 goi mang 2 phieu van keu 1 => badge noi doi (dung luat
  vong-3 "loc TRUOC khi dung cay").
- Tia nhanh 0-phieu: khong de nguoi dung mo Nam>Du an>Hang muc ba lan chi
  de thay cho trong.
- `emptyText` OPT-IN: cau mac dinh "Chua co phieu nao de dung cay" noi ve
  DUNG CAY, o che-do la se doc thanh he-thong hong thay vi "khong khop loc".
- 4 host con lai truyen 0 => di nhanh PipelineStageFolders y nhu cu.

KhkkListPanel:
- `flatMode` bo 2 ve `deletedView`/`phase`. GIU `!canPe` (cay dung tren
  xuong song PE, ep cay se lam ho mat trang phieu cua chinh minh) va GIU
  `term` (tim kiem di server-side co phan trang that = duong thoat cho
  phieu ngoai cua-so 200).
- Thung rac tren cay phai noi NGUON RIENG `/contract-signing-plans/deleted`
  pageSize 200 — linkage cua cay chi goi endpoint thuong nen KHONG he co
  phieu da xoa (grep 'deleted' trong hook = 0 hit).
- Phieu da xoa tren cay nay cu-xu y het o phang: lam mo + KHONG cho mo.
  Thieu ve nay thi cung mot phieu mo duoc o cay ma khong mo duoc o phang.

🔴 CAM them ve `group !== null` vao dieu-kien leafMode: CA BON leaf menu KHKK
deu mang `?group=1` (Layout.tsx:41-45), ke ca "Danh sach" khong loc — them
ve do la keo luon man TOAN CANH vao che-do la, tuc doi mot man owner khong
he yeu cau. Nhom la chieu CHIA NGAN, khong phai chieu CHON HINH CAY.

🔸 KHAI GIOI-HAN: cay an cua-so 200 phieu moi nhat => 3 menu nay thu hep tam
voi so voi phang-phan-trang-that. Dong khai truncation cua cay van hien;
duong thoat = go tim kiem. Volume KHKK hien nho nen chua can.

mirror sha256 khop 2/2 cap file; build xanh x2 app; tsc sach x2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-06 09:06:27 +07:00
parent 8cb1ec78b6
commit 097e41a085
5 changed files with 312 additions and 82 deletions

View File

@ -8,7 +8,7 @@
// 🔴 Cây trang Duyệt NCC (`pages/pe/PurchaseEvaluationsListPage.tsx:210-252`) KHÔNG
// bị đụng vào: nó có bộ lọc `pendingMe` + `?deleted=1` riêng. Bộ dựng nhóm ở đây
// là bản độc lập (duplicate CÓ CHỦ ĐÍCH) để trang đang chạy giữ nguyên hành vi.
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ListTree, Search } from 'lucide-react'
import { api } from '@/lib/api'
@ -31,7 +31,13 @@ type ProjectNode = {
}
type YearNode = { year: number; projects: ProjectNode[]; totalCount: number }
function buildTree(rows: PeListItem[]): YearNode[] {
// [S176 owner] `weightOf` = mỗi PE đóng góp BAO NHIÊU vào badge đếm. Mặc định 1 (đếm
// gói thầu) ⇒ 6 host cũ không đổi một số nào. Chế-độ lá (`leafMode`) truyền số PHIẾU
// khớp bộ lọc, vì ở 3 menu Đang duyệt / Đã duyệt / Đã xóa thứ người dùng đang đếm là
// PHIẾU chứ không phải gói — và 1 gói có thể mang nhiều phiếu.
// 🔴 Cùng luật vòng-3: đếm phải khớp ĐÚNG tập đang hiện. Giữ `+1` ở chế-độ lá thì badge
// nói dối theo chiều ngược lại (gói có 2 phiếu vẫn kêu 1).
function buildTree(rows: PeListItem[], weightOf: (p: PeListItem) => number = () => 1): YearNode[] {
const yearMap = new Map<number, YearNode>()
for (const p of rows) {
const year = new Date(p.createdAt).getFullYear()
@ -63,8 +69,9 @@ function buildTree(rows: PeListItem[]): YearNode[] {
pg.workItems.push(wg)
}
wg.items.push(p)
pg.totalCount++
yg.totalCount++
const w = weightOf(p)
pg.totalCount += w
yg.totalCount += w
}
const arr = Array.from(yearMap.values())
arr.sort((a, b) => b.year - a.year)
@ -92,6 +99,7 @@ export function PipelineTreePanel({
hideSearch,
approvedPeOnly,
stagesOnly,
leafMode,
}: {
currentStage: PipelineStageNo
className?: string
@ -126,6 +134,24 @@ export function PipelineTreePanel({
// [S175] Chỉ vẽ các folder giai-đoạn trong danh sách này (vd `[1]` = chỉ Duyệt NCC).
// Không truyền ⇒ vẽ đủ 4 giai-đoạn như cũ.
stagesOnly?: PipelineStageNo[]
// [S176 owner — "ở các menu đang duyệt/ đã duyệt/ xóa → cho hiển thị năm → dự án →
// hạng mục → phiếu KHKK nhé"] CHẾ-ĐỘ LÁ: cây dừng ở tầng Hạng mục rồi vẽ THẲNG phiếu,
// bỏ tầng folder giai-đoạn VÀ tầng 8 ngăn nhóm. Đúng 4 tầng anh gõ.
// 🔴 Panel này KHÔNG biết gì về kiểu KHKK — host cấp cả phép ĐẾM lẫn phép VẼ, nên cây
// dùng lại được cho giai-đoạn khác sau này mà không kéo theo import chéo.
// 🔴 OPT-IN: 6 host còn lại không truyền ⇒ đi nhánh `PipelineStageFolders` y như cũ.
leafMode?: {
/** Số phiếu của PE này SAU bộ lọc. Trả 0 ⇒ nhánh bị TỈA khỏi cây (không hiện folder rỗng). */
countForPe: (pe: PeListItem) => number
/** Thân tầng Hạng mục = phiếu của đúng các PE trong hạng mục đó. */
render: (peItems: PeListItem[]) => ReactNode
/**
* Câu hiển thị khi bộ lọc tỉa sạch cây. Mặc định "Chưa có phiếu nào để dựng cây" nói
* về DỰNG CÂY — ở chế-độ lá nó đánh lừa: người dùng lọc "Đã duyệt" mà rỗng sẽ tưởng
* hệ-thống hỏng chứ không hiểu là không phiếu nào khớp bộ lọc.
*/
emptyText?: string
}
}) {
const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('')
@ -170,14 +196,23 @@ export function PipelineTreePanel({
const all = approvedPeOnly
? base.filter(p => p.phase === PurchaseEvaluationPhase.DaDuyet)
: base
if (!term) return all
return all.filter(p =>
[p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau]
.some(v => (v ?? '').toLowerCase().includes(term)),
)
}, [peIndex.data, term, approvedPeOnly])
const searched = !term
? all
: all.filter(p =>
[p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau]
.some(v => (v ?? '').toLowerCase().includes(term)),
)
// [S176] TỈA nhánh rỗng ở chế-độ lá: gói thầu không còn phiếu nào khớp bộ lọc thì
// KHÔNG dựng nhánh cho nó. Cùng lý-do "lọc TRƯỚC khi dựng" ở trên — để lại nhánh rỗng
// thì người dùng mở Năm→Dự án→Hạng mục ba lần chỉ để thấy chỗ trống.
return leafMode ? searched.filter(p => leafMode.countForPe(p) > 0) : searched
}, [peIndex.data, term, approvedPeOnly, leafMode])
const years = useMemo(() => buildTree(rows), [rows])
// [S176] Chế-độ lá đếm PHIẾU (`countForPe`); mặc định đếm GÓI (mỗi PE = 1) như cũ.
const years = useMemo(
() => buildTree(rows, leafMode ? p => leafMode.countForPe(p) : undefined),
[rows, leafMode],
)
const packageCount = years.reduce((n, y) => n + y.projects.reduce((m, p) => m + p.workItems.length, 0), 0)
// Đang tìm kiếm ⇒ chỉ hiện folder CÓ nội dung khớp (không đổ 4 folder rỗng).
const hideEmpty = term.length > 0
@ -296,7 +331,9 @@ export function PipelineTreePanel({
)}
{canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && (
<p className="px-3 py-4 text-[12px] text-slate-500">
{term ? 'Không có gói thầu nào khớp từ khoá.' : 'Chưa có phiếu nào để dựng cây.'}
{term
? 'Không có gói thầu nào khớp từ khoá.'
: (leafMode?.emptyText ?? 'Chưa có phiếu nào để dựng cây.')}
</p>
)}
@ -351,19 +388,34 @@ export function PipelineTreePanel({
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">🧱</span>
<span className={cn('min-w-0 flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
{/* [S176] Chế-độ lá đếm PHIẾU, mặc định đếm GÓI — khớp đúng
thứ đang hiện ngay dưới nó. */}
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">
{leafMode
? wg.items.reduce((n, p) => n + leafMode.countForPe(p), 0)
: wg.items.length}
</span>
</summary>
<PipelineStageFolders
className="ml-3 border-l border-slate-200"
currentStage={currentStage}
hideEmpty={hideEmpty}
stages={(() => {
const built = buildStages(wg.items, stage2Options ?? {})
// [S175] `stagesOnly` = lọc HIỂN THỊ, KHÔNG đụng bộ dựng —
// 5 host kia không truyền thì `built` đi thẳng như cũ.
return stagesOnly ? built.filter(st => stagesOnly.includes(st.n)) : built
})()}
/>
{leafMode ? (
// [S176 owner] 4 tầng: Năm Dự án Hạng mục PHIẾU.
// Không đi qua `PipelineStageFolders` ⇒ không có tầng GĐ,
// không có 8 ngăn nhóm.
<div className="ml-3 border-l border-slate-200">
{leafMode.render(wg.items)}
</div>
) : (
<PipelineStageFolders
className="ml-3 border-l border-slate-200"
currentStage={currentStage}
hideEmpty={hideEmpty}
stages={(() => {
const built = buildStages(wg.items, stage2Options ?? {})
// [S175] `stagesOnly` = lọc HIỂN THỊ, KHÔNG đụng bộ dựng —
// 5 host kia không truyền thì `built` đi thẳng như cũ.
return stagesOnly ? built.filter(st => stagesOnly.includes(st.n)) : built
})()}
/>
)}
</details>
)
})}