[CLAUDE] FE-Admin: KHKK man Thao tac dung LAI cay Nam>Du an>Hang muc>4 GD (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m25s

Anh: "cho hien thi no giong the nay nhe" (chi sang cay o man Danh sach) + "Hien thi theo ngay
thang du an hang muc phieu duyet ncc va phieu KHKK tuong ung".

=== LAM GI ===
Tang tren man Thao tac nay DUNG LAI chinh `PipelineTreePanel` — cay
📅 Nam > 📁 Du an > 🧱 Hang muc > 4 folder giai doan, dung cay anh tro sang.

🔴 DUNG LAI thay vi dung ban thu hai: cay do DA co san tang NAM + gom hang muc + 4 folder;
chep lai la de 2 su-that, sua 1 cho quen cho kia. Lead vua tu tay dung cay 2 cap
(Du an > Hang muc) o luot truoc — nay GO HAN, vi no la ban thu hai cua cung mot hinh.

🔴 2 callback DA CO SAN, khong phai them moi:
· `onOpenPe` (them o dot D/O6) => bam phieu PE tren cay = nap THANG vao form tao, o lai man.
· `onOpenKhkk` => bam phieu KHKK = mo o panel 2.
Khong truyen thi cay tu `navigate` roi man — dung thu anh cam ("ko re-direct").

🔸 `bodyMaxHClass="max-h-none"`: `Tier` da tu cuon; de tran mac-dinh cua cay thi thanh 2
thanh cuon long nhau.

=== DON DEAD CODE (do go cay tu-dung) ===
· query `khkk-pe-candidates` GO khoi panel nay — cay tu nap index PE cua no; giu lai la goi
  mang cho danh sach khong con ai ve.
  🔸 `KhkkCreatePage` VAN dung key nay cho o chon PE cua form tao => cache dung chung, form
     tao KHONG mat du-lieu.
· go `awaiting` / `byProject` / `countProject` / `collapsed` / `formatDate` / prop
  `selectedPeId` — tat ca chi ton tai de phuc vu cay tu-dung.

=== HAU-KIEM host khac ===
4 host cay cua module Hop dong (ContractsListPage · HardCopiesPage x2 · MyContractsPage):
`onOpenPe` = 0, `hideSearch` = 0 => KHONG doi hanh vi. Ca 2 prop deu opt-in.

Verify dist bang NOI DUNG (khong tin thoi-gian build): chuoi moi 'Cay toan trinh — bam phieu'
= 1/1 ca 2 app; chuoi CU 'Da duyet · chua lap KHKK' = 0/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 21:53:39 +07:00
parent 99cabd8ebe
commit 0db1b99457
4 changed files with 56 additions and 300 deletions

View File

@ -24,9 +24,9 @@
// 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 { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react' import { FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { useState } from 'react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
@ -34,15 +34,11 @@ import {
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase, KhkkPhase,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPeCandidateDto,
type PagedResult, type PagedResult,
} from '@/types/khkk' } from '@/types/khkk'
const KHKK_TIER_PAGE_SIZE = 30 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 /** 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ệ. */ * `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */
@ -78,7 +74,6 @@ function Tier({
export function KhkkWorkListPanel({ export function KhkkWorkListPanel({
onPickPe, onPickPe,
onPickPlan, onPickPlan,
selectedPeId,
selectedPlanId, selectedPlanId,
onEditPlan, onEditPlan,
group, group,
@ -88,7 +83,6 @@ export function KhkkWorkListPanel({
/** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. Anh chốt: "click vào phiếu /** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. 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". */ * nào thì cho hiện phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc". */
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null
selectedPlanId?: string | null selectedPlanId?: string | null
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒ /** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
* không vẽ bút chì (cột giữ đúng hình cũ). */ * không vẽ bút chì (cột giữ đúng hình cũ). */
@ -98,22 +92,15 @@ export function KhkkWorkListPanel({
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */ * N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
group?: number | null group?: number | null
}) { }) {
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
// này tự mở, không bị ẩn im lặng vì chưa ai bấm mở bao giờ.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
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')
const candidates = useQuery({ // 🧊 [S175] Query `khkk-pe-candidates` ĐÃ GỠ khỏi panel này — tầng trên nay là
// Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì // `PipelineTreePanel`, nó TỰ nạp cây từ index PE của riêng nó. Giữ query cũ = gọi mạng
// cột tự tươi, không phải nhớ invalidate thêm một key thứ hai. // cho một danh sách không còn ai vẽ.
queryKey: ['khkk-pe-candidates'], // 🔸 `KhkkCreatePage` VẪN dùng key này cho ô chọn PE của form tạo — cache dùng chung,
queryFn: async () => // nên gỡ ở đây không làm form tạo mất dữ-liệu.
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
enabled: canKhkk,
})
const plans = useQuery({ const plans = useQuery({
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }], queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
@ -124,30 +111,7 @@ export function KhkkWorkListPanel({
enabled: canKhkk, 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)
// [S175 · O8] CÂY 2 CẤP **Dự án Hạng mục** — anh chốt "cho cây thư mục dự án hạng mục
// ra luôn nhé". Trước đây chỉ gom 1 cấp theo dự án, nên một dự án nhiều gói thầu đổ ra
// một danh sách phẳng dài, không thấy được ranh giới hạng mục.
// 🔴 0 ENDPOINT MỚI: `workItemName` ĐÃ có sẵn trong `KhkkPeCandidateDto` (`khkk.ts:423`)
// ⇒ cấp 2 là gom lại trên dữ-liệu đang có, không phải gọi thêm cửa nào.
// `Map` giữ thứ tự chèn ở CẢ HAI cấp nên dự án/hạng mục có phiếu mới nhất nổi lên trước
// (BE đã trả sẵn theo thứ tự).
const byProject = new Map<string, Map<string, KhkkPeCandidateDto[]>>()
for (const pe of awaiting) {
const pKey = pe.projectName ?? '(chưa gán dự án)'
const wKey = pe.workItemName ?? '(chưa gán hạng mục)'
let byWorkItem = byProject.get(pKey)
if (!byWorkItem) { byWorkItem = new Map(); byProject.set(pKey, byWorkItem) }
const bucket = byWorkItem.get(wKey)
if (bucket) bucket.push(pe)
else byWorkItem.set(wKey, [pe])
}
/** Đếm phiếu của cả một dự án (cộng mọi hạng mục con) — số ở header cấp 1. */
const countProject = (byWorkItem: Map<string, KhkkPeCandidateDto[]>) =>
[...byWorkItem.values()].reduce((s, arr) => s + arr.length, 0)
// [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG // [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG
// 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH: // 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH:
@ -179,115 +143,30 @@ export function KhkkWorkListPanel({
</div> </div>
) : ( ) : (
<> <>
{/* [S175 — anh: "cho hiển thị nó giống thế này nhé" + "Hiển thị theo ngày tháng dự
án hạng mục phiếu duyệt ncc và phiếu KHKK tương ứng"] Tầng trên nay DÙNG LẠI
CHÍNH `PipelineTreePanel` — cây 📅 Năm 📁 Dự án 🧱 Hạng mục 4 giai đoạn,
đúng cây anh chỉ sang ở màn Danh sách.
🔴 DÙNG LẠI thay vì dựng bản thứ hai: cây đó đã có sẵn tầng NĂM + gom hạng mục +
4 folder giai đoạn; chép lại là đẻ 2 sự-thật, sửa 1 chỗ quên chỗ kia.
🔴 2 callback đã có sẵn từ đợt trước: `onOpenPe` (O6) → nạp PE thẳng vào form tạo ·
`onOpenKhkk` → mở phiếu KHKK ở panel 2. Không truyền thì cây tự `navigate` rời màn
— đúng thứ anh cấm ("ko re-direct").
🔸 `bodyMaxHClass="max-h-none"`: `Tier` đã tự cuộn rồi, để trần mặc-định của cây
thì thành 2 thanh cuộn lồng nhau. */}
<Tier <Tier
title="Đã duyệt · chưa lập KHKK" title="Cây toàn trình — bấm phiếu để lập kế hoạch"
count={awaiting.length} count={null}
accent="bg-amber-50 text-amber-600" accent="bg-amber-50 text-amber-600"
icon={<FileCheck className="h-3 w-3" />} icon={<FolderTree className="h-3 w-3" />}
> >
{candidates.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>} <PipelineTreePanel
{candidates.isError && ( currentStage={2}
<div className="space-y-1.5 p-3"> hideHeader
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p> hideSearch
<button bodyMaxHClass="max-h-none"
type="button" stage2Options={{ onOpenPe: onPickPe, onOpenKhkk: onPickPlan }}
onClick={() => candidates.refetch()} />
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
>
Thử lại
</button>
</div>
)}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-500">
Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p>
)}
{/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó,
`z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì
header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */}
{[...byProject.entries()].map(([projectName, byWorkItem]) => {
const pCollapsed = collapsed.has(projectName)
return (
<div key={projectName}>
{/* [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Header CẤP 1 nay là
NÚT gập/mở. Mặc-định MỞ: gập sẵn thì mở panel ra thấy toàn thư-mục đóng,
người dùng tưởng chưa có việc nào. */}
<button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(projectName)) next.delete(projectName); else next.add(projectName)
return next
})}
aria-expanded={!pCollapsed}
className="sticky top-0 z-20 flex w-full items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 text-left backdrop-blur transition hover:bg-slate-100"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-400 transition-transform', !pCollapsed && 'rotate-90')} aria-hidden />
<FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
{projectName}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
</button>
{!pCollapsed && [...byWorkItem.entries()].map(([workItemName, items]) => {
const wKey = `${projectName}${workItemName}`
const wCollapsed = collapsed.has(wKey)
return (
<div key={workItemName}>
<button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(wKey)) next.delete(wKey); else next.add(wKey)
return next
})}
aria-expanded={!wCollapsed}
className="sticky top-[1.375rem] z-10 flex w-full items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-5 pr-3 text-left backdrop-blur transition hover:bg-slate-50"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-300 transition-transform', !wCollapsed && 'rotate-90')} aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-500">
{workItemName}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-400">{items.length}</span>
</button>
{!wCollapsed && (
<ul className="divide-y divide-slate-100 border-l-2 border-slate-100 pl-1">
{items.map((pe) => (
<li key={pe.id}>
<button
type="button"
// 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,
// không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
onClick={() => onPickPe(pe.id)}
title="Lập kế hoạch ký kết cho phiếu này"
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">
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="text-[10px] text-slate-400">·</span>
<span className="truncate text-[10px] text-slate-500">{formatDate(pe.createdAt)}</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">{pe.tenGoiThau}</div>
{pe.winnerSupplierNames.length > 0 && (
<div className="mt-0.5 truncate text-[10px] text-emerald-700">
{pe.winnerSupplierNames.join(' · ')}
</div>
)}
</button>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
)
})}
</Tier> </Tier>
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />

View File

@ -198,7 +198,6 @@ export function KhkkCreatePage() {
<KhkkWorkListPanel <KhkkWorkListPanel
onPickPe={pickPe} onPickPe={pickPe}
onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }} onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }}
selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }} onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
group={approvalGroup} group={approvalGroup}

View File

@ -24,9 +24,9 @@
// 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 { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react' import { FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { useState } from 'react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { import {
@ -34,15 +34,11 @@ import {
KHKK_PHASE_LABELS, KHKK_PHASE_LABELS,
KhkkPhase, KhkkPhase,
type KhkkListItemDto, type KhkkListItemDto,
type KhkkPeCandidateDto,
type PagedResult, type PagedResult,
} from '@/types/khkk' } from '@/types/khkk'
const KHKK_TIER_PAGE_SIZE = 30 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 /** 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ệ. */ * `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */
@ -78,7 +74,6 @@ function Tier({
export function KhkkWorkListPanel({ export function KhkkWorkListPanel({
onPickPe, onPickPe,
onPickPlan, onPickPlan,
selectedPeId,
selectedPlanId, selectedPlanId,
onEditPlan, onEditPlan,
group, group,
@ -88,7 +83,6 @@ export function KhkkWorkListPanel({
/** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. Anh chốt: "click vào phiếu /** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. 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". */ * nào thì cho hiện phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc". */
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null
selectedPlanId?: string | null selectedPlanId?: string | null
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒ /** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
* không vẽ bút chì (cột giữ đúng hình cũ). */ * không vẽ bút chì (cột giữ đúng hình cũ). */
@ -98,22 +92,15 @@ export function KhkkWorkListPanel({
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */ * N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
group?: number | null group?: number | null
}) { }) {
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
// này tự mở, không bị ẩn im lặng vì chưa ai bấm mở bao giờ.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
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')
const candidates = useQuery({ // 🧊 [S175] Query `khkk-pe-candidates` ĐÃ GỠ khỏi panel này — tầng trên nay là
// Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì // `PipelineTreePanel`, nó TỰ nạp cây từ index PE của riêng nó. Giữ query cũ = gọi mạng
// cột tự tươi, không phải nhớ invalidate thêm một key thứ hai. // cho một danh sách không còn ai vẽ.
queryKey: ['khkk-pe-candidates'], // 🔸 `KhkkCreatePage` VẪN dùng key này cho ô chọn PE của form tạo — cache dùng chung,
queryFn: async () => // nên gỡ ở đây không làm form tạo mất dữ-liệu.
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
enabled: canKhkk,
})
const plans = useQuery({ const plans = useQuery({
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }], queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
@ -124,30 +111,7 @@ export function KhkkWorkListPanel({
enabled: canKhkk, 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)
// [S175 · O8] CÂY 2 CẤP **Dự án Hạng mục** — anh chốt "cho cây thư mục dự án hạng mục
// ra luôn nhé". Trước đây chỉ gom 1 cấp theo dự án, nên một dự án nhiều gói thầu đổ ra
// một danh sách phẳng dài, không thấy được ranh giới hạng mục.
// 🔴 0 ENDPOINT MỚI: `workItemName` ĐÃ có sẵn trong `KhkkPeCandidateDto` (`khkk.ts:423`)
// ⇒ cấp 2 là gom lại trên dữ-liệu đang có, không phải gọi thêm cửa nào.
// `Map` giữ thứ tự chèn ở CẢ HAI cấp nên dự án/hạng mục có phiếu mới nhất nổi lên trước
// (BE đã trả sẵn theo thứ tự).
const byProject = new Map<string, Map<string, KhkkPeCandidateDto[]>>()
for (const pe of awaiting) {
const pKey = pe.projectName ?? '(chưa gán dự án)'
const wKey = pe.workItemName ?? '(chưa gán hạng mục)'
let byWorkItem = byProject.get(pKey)
if (!byWorkItem) { byWorkItem = new Map(); byProject.set(pKey, byWorkItem) }
const bucket = byWorkItem.get(wKey)
if (bucket) bucket.push(pe)
else byWorkItem.set(wKey, [pe])
}
/** Đếm phiếu của cả một dự án (cộng mọi hạng mục con) — số ở header cấp 1. */
const countProject = (byWorkItem: Map<string, KhkkPeCandidateDto[]>) =>
[...byWorkItem.values()].reduce((s, arr) => s + arr.length, 0)
// [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG // [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG
// 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH: // 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH:
@ -179,115 +143,30 @@ export function KhkkWorkListPanel({
</div> </div>
) : ( ) : (
<> <>
{/* [S175 — anh: "cho hiển thị nó giống thế này nhé" + "Hiển thị theo ngày tháng dự
án hạng mục phiếu duyệt ncc và phiếu KHKK tương ứng"] Tầng trên nay DÙNG LẠI
CHÍNH `PipelineTreePanel` — cây 📅 Năm 📁 Dự án 🧱 Hạng mục 4 giai đoạn,
đúng cây anh chỉ sang ở màn Danh sách.
🔴 DÙNG LẠI thay vì dựng bản thứ hai: cây đó đã có sẵn tầng NĂM + gom hạng mục +
4 folder giai đoạn; chép lại là đẻ 2 sự-thật, sửa 1 chỗ quên chỗ kia.
🔴 2 callback đã có sẵn từ đợt trước: `onOpenPe` (O6) → nạp PE thẳng vào form tạo ·
`onOpenKhkk` → mở phiếu KHKK ở panel 2. Không truyền thì cây tự `navigate` rời màn
— đúng thứ anh cấm ("ko re-direct").
🔸 `bodyMaxHClass="max-h-none"`: `Tier` đã tự cuộn rồi, để trần mặc-định của cây
thì thành 2 thanh cuộn lồng nhau. */}
<Tier <Tier
title="Đã duyệt · chưa lập KHKK" title="Cây toàn trình — bấm phiếu để lập kế hoạch"
count={awaiting.length} count={null}
accent="bg-amber-50 text-amber-600" accent="bg-amber-50 text-amber-600"
icon={<FileCheck className="h-3 w-3" />} icon={<FolderTree className="h-3 w-3" />}
> >
{candidates.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>} <PipelineTreePanel
{candidates.isError && ( currentStage={2}
<div className="space-y-1.5 p-3"> hideHeader
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p> hideSearch
<button bodyMaxHClass="max-h-none"
type="button" stage2Options={{ onOpenPe: onPickPe, onOpenKhkk: onPickPlan }}
onClick={() => candidates.refetch()} />
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
>
Thử lại
</button>
</div>
)}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-500">
Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p>
)}
{/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó,
`z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì
header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */}
{[...byProject.entries()].map(([projectName, byWorkItem]) => {
const pCollapsed = collapsed.has(projectName)
return (
<div key={projectName}>
{/* [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Header CẤP 1 nay là
NÚT gập/mở. Mặc-định MỞ: gập sẵn thì mở panel ra thấy toàn thư-mục đóng,
người dùng tưởng chưa có việc nào. */}
<button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(projectName)) next.delete(projectName); else next.add(projectName)
return next
})}
aria-expanded={!pCollapsed}
className="sticky top-0 z-20 flex w-full items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 text-left backdrop-blur transition hover:bg-slate-100"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-400 transition-transform', !pCollapsed && 'rotate-90')} aria-hidden />
<FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
{projectName}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
</button>
{!pCollapsed && [...byWorkItem.entries()].map(([workItemName, items]) => {
const wKey = `${projectName}${workItemName}`
const wCollapsed = collapsed.has(wKey)
return (
<div key={workItemName}>
<button
type="button"
onClick={() => setCollapsed(prev => {
const next = new Set(prev)
if (next.has(wKey)) next.delete(wKey); else next.add(wKey)
return next
})}
aria-expanded={!wCollapsed}
className="sticky top-[1.375rem] z-10 flex w-full items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-5 pr-3 text-left backdrop-blur transition hover:bg-slate-50"
>
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-300 transition-transform', !wCollapsed && 'rotate-90')} aria-hidden />
<span className="min-w-0 flex-1 truncate text-[10px] font-medium text-slate-500">
{workItemName}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-400">{items.length}</span>
</button>
{!wCollapsed && (
<ul className="divide-y divide-slate-100 border-l-2 border-slate-100 pl-1">
{items.map((pe) => (
<li key={pe.id}>
<button
type="button"
// 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,
// không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
onClick={() => onPickPe(pe.id)}
title="Lập kế hoạch ký kết cho phiếu này"
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">
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="text-[10px] text-slate-400">·</span>
<span className="truncate text-[10px] text-slate-500">{formatDate(pe.createdAt)}</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">{pe.tenGoiThau}</div>
{pe.winnerSupplierNames.length > 0 && (
<div className="mt-0.5 truncate text-[10px] text-emerald-700">
{pe.winnerSupplierNames.join(' · ')}
</div>
)}
</button>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
)
})}
</Tier> </Tier>
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />

View File

@ -198,7 +198,6 @@ export function KhkkCreatePage() {
<KhkkWorkListPanel <KhkkWorkListPanel
onPickPe={pickPe} onPickPe={pickPe}
onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }} onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }}
selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }} onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
group={approvalGroup} group={approvalGroup}