Files
solution-erp/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx
pqhuy1987 99cabd8ebe
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s
[CLAUDE] Contract: KHKK UAT S175 dot-F — gom nut thao tac + hang muc du an + loc nhom (0-mig)
Anh review truc tiep tren prod, khoanh do tung cho. Land tron cum:

=== 1. GOM NUT THAO TAC (anh: "Bo het di" + "phia duoi them cac nut thao tac"
       + "cho menu dang duyet -> Duyet/Tra lai vay thoi") ===
· THANH NUT DAY moi o `KhkkDetailContent` (sticky bottom-0, khuon PE workspace):
  [Xoa phieu] · dong "Cac thay doi da tu dong luu" · [Luu & Gui duyet ->]
· Panel 3 GO "Gui duyet" + "Xoa phieu" => nay CHI con **Duyet / Tra lai**.
· GO nut "+ Lap ke hoach" (header) · nut "Sua" (muc 1) · dai banner "Dang xem ke hoach".
🔴 THU TU CO CHU DICH: KHONG go "Gui duyet" o panel 3 TRUOC khi thanh day co mat — go truoc
  la nguoi soan mat duong trinh duyet, phieu ket o Nhap. Vi vay ca cum di CHUNG 1 commit.
🔴 Thanh day chi om nhanh **Submit** — nhanh Approve/Return (o-tich level-finalize + upload
  file sau transition) O LAI panel 3. Nhan ban nhanh do sang day la de su-that thu hai cho
  duong DUYET.
🔸 KHONG lam nut "Luu" roi: moi o o man nay luu NGAY khi roi o. Them nut khong lam gi la
  hua hao — noi thang bang dong chu ben canh thay vi ve nut gia.
🔸 Giu lai dung 1 dong nho "Ve form tao" — bo han thi mo 1 phieu la ket, khong co duong lui.
· `onDeleted` noi vao ca 3 host (List / Create / Detail) de xoa xong khong dung o phieu ma.

=== 2. HANG MUC CUA DU AN (anh: "hang muc cua Du an -. Duyet NCC ay") ===
🔴 Lead bay NHAM o luot truoc: bay `line.tenHangMuc` = danh-muc **SP-002** (86 dong, dung
  phan loai HOP DONG). Anh muon **hang muc CONG VIEC cua du an** tu phieu Duyet NCC
  (`PE.WorkItemId`). Hai thu trung ten tieng Viet nhung KHAC TRUC — tron la chi sai goi thau.
· BE: `ContractSigningPlanDetailDto` +`PeWorkItemName`; handler join `WorkItems` (loose-Guid
  Mig 49, KHONG FK vat ly => join tay null-safe).
· FE: o "Hang muc (du an)" doc `plan.peWorkItemName`; go het khoi liet-ke SP-002 khoi muc 1.

=== 3. MUC 2 RUT VE "HANG MUC + GIA -> TONG" ===
Anh: "Cho nay doi lai la hang muc la dc roi, chon hang muc tien -> thanh tong con lai bo het"
 + "cho chon nhieu dong nhap gia thanh gia tong, ko can so sanh j voi so duyet PRO hay CCM".
· Tieu de: "2. Gia de xuat theo NCC trung thau" -> "2. Hang muc".
· Cot: 6 -> 3 (HANG MUC | GIA | thao-tac). GO **Nha cung cap · Gia tham chieu · Gia duyet**.
· Ghi chu xuong DONG PHU duoi o hang muc (van nhap duoc, khong chiem cot).
· +hang TONG = Sigma gia cac dong.
🔸 Du-lieu 2 cot go di VAN trong DTO; `peReferenceAmount` con dung lam DAU phan biet
  dong-goc / dong-them-tay o nut xoa.

=== 4. LOC THEO NHOM (anh: "Loc theo quy trinh luon") ===
Tang duoi man Thao tac hien phieu N1/N7 trong khi dang dung o leaf N2 => doc nham "nhom nay
dang co viec". Nay loc theo `approvalGroup` cua leaf menu. `group == null` => khong loc
(giu duong cu cho host khong truyen).

=== 5. GO DAI ACCENT + 2 O LINK HO SO/GHI CHU ===
· `card-accent` boc `KhkkDetailContent` GO HAN (dai xanh doc man Thao tac). GO CLASS chu
  KHONG de `border-0` — class nay ngoai `@layer` nen thang utility (gotcha #66).
· Khoi "Link ho so + Ghi chu" GO TRON ca 2 nhanh ternary. Luot truoc lead moi go o luoi tren
  nen 2 o van hien o khoi duoi — anh chup lai dung cho do.

=== CON NO ===
🔴 Cay man Thao tac: anh muon **Nam > Du an > Hang muc > 4 giai doan** (giong cay o man Danh
  sach). Hien moi co Du an > Hang muc (gap/mo duoc). Chua land.
🔴 CHUA soi mat prod 375/1366/1440.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:47:12 +07:00

410 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// [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,
// 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 — "việc đã làm rồi"; bấm = XEM tại chỗ (read-only).
// 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:
// · `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<KhkkListItemDto>`.
//
// 🔴 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 { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { useState } from 'react'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
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 (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className={cn('inline-flex h-5 w-5 items-center justify-center rounded', accent)} aria-hidden>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
{title}
</span>
{count !== null && (
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{count}
</span>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</div>
)
}
export function KhkkWorkListPanel({
onPickPe,
onPickPlan,
selectedPeId,
selectedPlanId,
onEditPlan,
group,
}: {
/** 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
/** 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". */
onPickPlan: (planId: string) => void
selectedPeId?: 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 ⇒
* không vẽ bút chì (cột giữ đúng hình cũ). */
onEditPlan?: (planId: string) => void
/** [S175 - anh: "Loc theo quy trinh luon"] Nhom duyet cua leaf menu dang dung.
* Tang duoi CHI hien ke hoach thuoc dung nhom nay - dung o leaf N2 ma thay phieu
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
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()
// 🔴 ĐÚ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<KhkkPeCandidateDto[]>('/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<PagedResult<KhkkListItemDto>>('/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)
// [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
// 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:
// loại-trừ (`!== DaDuyet`) sẽ tự động NHẬN mọi phase mới thêm về sau; danh-sách-trắng thì
// phase mới phải được khai TƯỜNG MINH mới hiện. Ở màn Thao tác — nơi chỉ nên có việc CÒN
// SỬA ĐƯỢC — im lặng nhận thêm là hỏng, nên chọn vế chặt.
// 🔸 `ChoDuyet` CŨNG bị loại (khác lượt trước chỉ bỏ `DaDuyet`): phiếu đang trình duyệt thì
// người soạn không thao tác gì được nữa.
// 🔴 KHAI GIỚI-HẠN (lọc ở FE): endpoint chỉ nhận `phase` ĐƠN, không nhận nhiều ⇒ (1) KHÔNG
// dùng `plans.data.total` làm số hiển thị (nó đếm MỌI phase) — đếm từ mảng đã lọc;
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
const planItems = (plans.data?.items ?? [])
.filter((k) => OPEN_PHASES.includes(k.phase))
// [S175] +loc NHOM. `group == null` => khong loc (giu duong cu cho host khong truyen).
.filter((k) => group == null || k.approvalGroup === group)
return (
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
{!canKhkk ? (
// 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">
<Lock className="h-5 w-5 text-slate-300" />
<p className="text-[11px] text-slate-500">
Bạn chưa quyền xem Kế hoạch kết nên cột này không dựng đưc. Việc duyệt NCC
hai panel bên trái không bị nh hưởng.
</p>
</div>
) : (
<>
<Tier
title="Đã duyệt · chưa lập KHKK"
count={awaiting.length}
accent="bg-amber-50 text-amber-600"
icon={<FileCheck className="h-3 w-3" />}
>
{candidates.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>}
{candidates.isError && (
<div className="space-y-1.5 p-3">
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p>
<button
type="button"
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>
<div className="shrink-0 border-t-2 border-slate-200" />
<Tier
// Tên nói ĐÚNG tập đang hiện, không nói chung chung — người đọc phải biết ngay
// vì sao phiếu họ vừa gửi duyệt "biến mất" khỏi đây.
title="KHKK · nháp / trả lại / từ chối"
// Đếm từ mảng ĐÃ LỌC, KHÔNG lấy `total` của server (xem khối lọc phía trên).
count={plans.data ? planItems.length : null}
accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />}
>
{plans.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>}
{plans.isError && (
<div className="space-y-1.5 p-3">
<p className="text-[11px] text-slate-500">Không tải đưc danh sách.</p>
<button
type="button"
onClick={() => plans.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>
)}
{!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-500">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết .
</p>
)}
<ul className="divide-y divide-slate-100">
{planItems.map((k) => (
<li key={k.id} className="relative">
<button
type="button"
// 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện
// phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc".
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',
// [S175 · O7] Chừa chỗ cho bút chì để tên gói thầu dài không chui xuống dưới nó.
onEditPlan && 'pr-9',
)}
>
<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>
{k.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700">
N{k.approvalGroup}
</span>
)}
<span
className={cn(
'ml-auto shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-medium',
KHKK_PHASE_BADGE[k.phase],
)}
>
{KHKK_PHASE_LABELS[k.phase]}
</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">
{k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</div>
</button>
{/* [S175 · O7] Bút chì — khuôn `PeListPanel.tsx:208-232`. LUÔN hiện (không
chỉ khi hover) rồi MỜ ĐI khi không sửa được: nút chỉ-hiện-khi-hover thì
trên máy cảm-ứng không ai tìm ra, còn ẩn hẳn khi phiếu khoá thì người
dùng tưởng chức năng không tồn tại thay vì hiểu là phiếu đã đi duyệt.
🔴 Nút NGOÀI thẻ <button> của card — lồng button trong button là HTML
không hợp lệ, React sẽ cảnh báo và bấm sẽ kích cả hai. */}
{onEditPlan && (() => {
const editable = k.phase === KhkkPhase.DangSoanThao || k.phase === KhkkPhase.TraLai
return (
<button
type="button"
onClick={() => editable && onEditPlan(k.id)}
disabled={!editable}
title={editable
? 'Sửa kế hoạch này'
: 'Kế hoạch đã gửi duyệt / đã duyệt — không sửa được'}
aria-label={editable ? `Sửa kế hoạch ${k.maKeHoach ?? ''}` : undefined}
className={cn(
'absolute right-2 top-2 rounded p-1.5 transition',
editable
? 'cursor-pointer text-violet-600 hover:bg-violet-100 hover:shadow-sm'
: 'cursor-not-allowed text-slate-300',
)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)
})()}
</li>
))}
</ul>
{/* 🔴 KHÔNG in "x/total" nữa: `total` gồm cả phiếu đã duyệt vừa bị lọc ⇒ tỉ số đó
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-500">
Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p>
)}
</Tier>
</>
)}
{/* 🧊 [S174] KHÔNG có nút "+ Thêm mới" ở đáy — anh chốt bỏ. Lý do đứng vững: màn này
KHÔNG có đường "tạo từ số 0". Một kế hoạch LUÔN sinh ra từ một phiếu Duyệt NCC, mà
các phiếu đủ điều kiện đã nằm nguyên ở tầng trên ⇒ nút chỉ xoá form chứ không tạo
thêm được gì. Đây là chỗ khác CÓ CHỦ ĐÍCH với `PeListPanel` (bên đó tạo phiếu PE
mới là việc có thật). Ai định thêm lại: phải trả lời được "thêm mới CÁI GÌ". */}
</aside>
)
}