[CLAUDE] Contract: UAT S174 dot-D + C3 — O6/O4/O7/O8 + 2 tong toan phieu (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s

Owner S175 chot cach doc "sum tong" (gate TIEN): (1) truc = CHI TONG TOAN PHIEU, KHONG co
dong tong per-NCC; (2) hien 2 TONG RIENG canh nhau — Sigma gia DE XUAT ⟂ Sigma gia DUYET,
dat PHIA TREN bang. Mirror 9 CAP file sha256 khop 9/9; build XANH ca 2 app, 0 error TS.

=== O6 — bam card PE tren cay GIU NGUYEN man KHKK ===
+callback `onOpenPe` (usePipelineStages) mirror DOI XUNG `onOpenKhkk` cua leaf GD2.
KHONG truyen => giu `navigate('/purchase-evaluations?id=..&expand=1')` => 5 host cay con lai
0 doi hanh vi (da verify: chi KhkkListPage truyen).
Host: `?pe=<id>` render `PeDetailTabs readOnly mode="workspace"` ngay panel 2 — DUNG LAI
renderer that cua man PE thay vi dung ban rut gon thu hai (2 ban = 2 su that).
🔴 `pe` va `id` LOAI TRU NHAU (2 setter cung xoa key kia): panel 2 chi ve duoc mot thu, de
ca hai thi lan chon sau khong xoa duoc lan truoc.
`<lg` roi ve duong cu (khong co panel 2 thi xem-tai-cho la bat kha).
Panel 3 doi chu khi dang xem PE — cau cu "se hien khi chon phieu" la SAI (phieu DA chon roi).

=== O4 — shopdrawing / can cu ho so cho dinh kem file (FE-only) ===
🔴 0 BE MOI — rail da du: controller `[FromForm] Guid? dossierItemId` (:181) + DTO
`KhkkAttachmentDto.dossierItemId` (:241) co tu Mig 69. FE truoc day don gian la KHONG BAO GIO
gui truong do len => moi file roi ve muc PHIEU. Nay upload gui kem dossierItemId.
Chia 2 ro: file gan can cu hien ngay DUOI dong can cu do; Section 4 chi con file muc phieu —
khong chia thi file hien o CA HAI cho, nguoi dung dem 2 lan.
Dong-con file hien CA KHI read-only (nguoi duyet can xem shopdrawing, chi khong them/xoa).

=== O7 — nut edit tren card KHKK (man Thao tac) ===
But chi khuon `PeListPanel:208-232`. LUON hien roi MO DI khi khong sua duoc: nut chi-hien-khi-
hover thi tren may cam-ung khong ai tim ra; an han khi phieu khoa thi nguoi dung tuong chuc
nang khong ton tai thay vi hieu la phieu da di duyet.
🔴 Nut NGOAI the <button> cua card — long button trong button la HTML khong hop le.
Host: but chi => mo che-do SUA; than card => chi xem nhu cu. Guard readOnly Y HET
`KhkkListPage:147-150` (cung mot cau hoi "ai duoc sua" phai cho cung mot cau tra loi o moi man).
Da verify khong phai co-hoc: `KhkkDetailContent:161` `isDraft = draftPhase && !readOnly`.

=== O8 — cay 2 cap Du an > Hang muc ===
🔴 0 ENDPOINT MOI: `workItemName` DA co san trong `KhkkPeCandidateDto:423` => cap 2 la gom lai
tren du-lieu dang co. Truoc day 1 cap theo du an nen mot du an nhieu goi thau do ra danh sach
phang dai, khong thay ranh gioi hang muc.
🔴 2 muc `z` khac nhau (z-20 cha / z-10 con): cung z thi header hang muc truot DE len header
du an khi cuon, mat cap cha.

=== C3 — Field: value ve slate-900, label giu brand-600 ===
Truoc day MOI value deu to brand => mau thuong-hieu mat het suc nang: ca trang deu la mau nhan
thi khong con gi la nhan, va khong chua duoc mau cho thu THAT SU can noi (NCC trung thau,
canh bao vuot ngan sach). PE giu slate cho value dung vi ly do nay.

=== O3 — MOI LAND VE (2). VE (1) CHUA LAM — xem CON NO ===
🔴 KHONG cong gop 2 cot thanh mot so "hien hanh": de-xuat va duyet la 2 so co y nghia phap-ly
khac nhau (mot cai chua ai chot, mot cai da qua choke-point `ApplyApprovedValuesOnFinalize`);
tron lai la bia ra mot so khong ton tai trong ho-so nao.
Tong duyet chi cong phan DA co, va noi ro con bao nhieu dong chua chot.

=== 🔴 CON NO — O3 ve (1) "hang muc cho chon NHIEU" KHONG PHAI FE-ONLY ===
Spec ghi O3 = "N-ROW 0-migration". "0-migration" DUNG (unique 3 cot PlanId+SupplierId+
CatalogEntryId co tu Mig 71) NHUNG KHONG co nghia la 0 BE. Do that:
  · KHONG co endpoint TAO line. Chi co `PUT /{id}/lines/{lineId}` = gan catalogEntryId cho
    dong DA CO (Controller:121). Danh sach route day du: 0 route POST/DELETE line.
  · Line duoc auto-sinh 1-dong-moi-NCC-trung-thau luc TAO PHIEU (`Features.cs:508-527`),
    tien moi = tong bao gia DUOC CHON cua NCC do.
=> Muon 1 NCC co N hang muc phai TACH tien cua NCC do ra N phan — va KHONG CO quy tac nao
   trong he thong noi chia the nao. Do la cau hoi TIEN, chua ai tra loi.
🔸 Tin tot: BE cau HD da nhan N dong san (`CreateContractFromSigningPlanFeatures:101-115`
   nhan `LineIds` list, chi rao "moi HD 1 NCC"). Cai hep la FE dialog dung radio single-select
   (`KhkkDetailPage:310`) — do la UI, doi duoc.

=== CON NO KHAC ===
🔴 CHUA soi mat tren prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3) — moi ket luan la
DOC MA, khong phai da nhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 19:59:04 +07:00
parent 43e1bd7bc2
commit a0ccac3982
14 changed files with 758 additions and 142 deletions

View File

@ -18,7 +18,7 @@
// Không dựng workspace riêng cho việc này.
// [R-8] KHKK KHÔNG có phiếu V1-legacy (module sinh 2026-07-29) ⇒ KHÔNG port nhánh fallback
// V1 của PE (`PeDetailTabs.tsx:434-436`) — đó là code chết ngay ngày đầu.
import { useState, type ReactNode } from 'react'
import { Fragment, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
@ -123,7 +123,11 @@ function Field({
return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
<div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-brand-800', mono && !empty && 'font-mono')}>
{/* [S175 · C3] VALUE về `text-slate-900`, LABEL giữ `text-brand-600`. Trước đây MỌI
value đều tô brand ⇒ màu thương-hiệu mất hết sức nặng: khi cả trang đều là màu nhấn
thì không còn gì là nhấn, và không chừa được màu cho thứ THẬT SỰ cần nổi (NCC trúng
thầu, cảnh báo vượt ngân sách). PE giữ slate cho value đúng vì lý do này. */}
<div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-slate-900', mono && !empty && 'font-mono')}>
{children ?? (empty ? '—' : value)}
</div>
</div>
@ -218,11 +222,16 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)),
})
// [S175 · O4] +`dossierItemId`: gắn file vào ĐÚNG một căn cứ hồ sơ (shopdrawing, mẫu vật
// liệu…) thay vì đổ chung một rổ. 🔴 0 BE MỚI — rail `dossierItemId` đã đủ 6 tầng từ Mig 69
// (`KhkkAttachmentDto:241` ⟵ `POST /attachments` multipart `dossierItemId?`); trước đây FE
// đơn giản là KHÔNG BAO GIỜ gửi trường đó lên, nên mọi file rơi về mức phiếu.
const upload = useMutation({
mutationFn: async (file: File) => {
mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => {
const fd = new FormData()
fd.append('file', file)
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
if (dossierItemId) fd.append('dossierItemId', dossierItemId)
return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
@ -253,11 +262,11 @@ export function KhkkDetailContent({
}
}
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>, dossierItemId?: string) {
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* lỗi đã toast ở onError */ }
try { await upload.mutateAsync({ file: f, dossierItemId }) } catch { /* lỗi đã toast ở onError */ }
}
}
@ -268,7 +277,21 @@ export function KhkkDetailContent({
}
// File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu.
const planAttachments = plan.attachments.filter(a => a.purpose !== KhkkAttachmentPurpose.ApprovalAttachment)
// [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4
// chỉ còn file mức PHIẾU. 🔴 Không chia thì file gắn căn cứ hiện ở CẢ HAI chỗ — người dùng
// đếm 2 lần rồi tưởng đã tải nhầm lên hai bận.
const dossierAttachments = new Map<string, KhkkAttachmentDto[]>()
const planAttachments: KhkkAttachmentDto[] = []
for (const a of plan.attachments) {
if (a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) continue
if (a.dossierItemId) {
const bucket = dossierAttachments.get(a.dossierItemId)
if (bucket) bucket.push(a)
else dossierAttachments.set(a.dossierItemId, [a])
} else {
planAttachments.push(a)
}
}
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
@ -389,10 +412,54 @@ export function KhkkDetailContent({
{plan.lines.length === 0 ? (
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa NCC trúng thầu.</div>
) : (
// [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn tự-do
// trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không thẳng hàng
// giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG (đúng ý đồ của
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ.
<>
{/* [S175 · O3 — ANH CHỐT] TỔNG đặt PHÍA TRÊN bảng, đúng 2 con số:
① trục = CHỈ TỔNG TOÀN PHIẾU (anh bác phương án tổng-mỗi-NCC);
② hiện 2 TỔNG RIÊNG cạnh nhau — Σ giá ĐỀ XUẤT ⟂ Σ giá DUYỆT.
🔴 KHÔNG cộng gộp 2 cột thành một số "hiện hành": đề-xuất và duyệt là hai
con số có ý nghĩa pháp-lý khác nhau (một cái chưa ai chốt, một cái đã qua
choke-point `ApplyApprovedValuesOnFinalize`); trộn lại là bịa ra một số
không tồn tại trong hồ-sơ nào.
🔸 Dòng chưa có giá duyệt ⇒ `null`, `moneyOrDash` in em-dash mờ; tổng duyệt
chỉ cộng phần ĐÃ có, và nói rõ còn bao nhiêu dòng chưa chốt. */}
{(() => {
const sumProposed = plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0)
const approvedLines = plan.lines.filter(l => l.approvedAmount != null)
const sumApproved = approvedLines.reduce((s, l) => s + (l.approvedAmount ?? 0), 0)
const pending = plan.lines.length - approvedLines.length
return (
<div className="mb-3 flex flex-wrap items-stretch gap-2">
<div className="min-w-[13rem] flex-1 rounded-md border border-slate-200 bg-white px-3 py-2">
<div className="label-eyebrow">Tổng giá đ xuất</div>
<div className="mt-0.5 text-base font-semibold tabular-nums text-brand-800">
{formatVnd(sumProposed)}
</div>
<div className="text-[10px] text-slate-500">{plan.lines.length} dòng</div>
</div>
<div className={cn(
'min-w-[13rem] flex-1 rounded-md border px-3 py-2',
pending === 0 ? 'border-emerald-300 bg-emerald-50' : 'border-slate-200 bg-white',
)}>
<div className="label-eyebrow">Tổng giá duyệt</div>
<div className={cn(
'mt-0.5 text-base font-semibold tabular-nums',
pending === 0 ? 'text-emerald-700' : 'text-slate-400',
)}>
{approvedLines.length === 0 ? '—' : formatVnd(sumApproved)}
</div>
<div className="text-[10px] text-slate-500">
{pending === 0
? 'đã chốt đủ mọi dòng'
: `còn ${pending}/${plan.lines.length} dòng chưa chốt giá`}
</div>
</div>
</div>
)
})()}
{/* [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn
tự-do trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không
thẳng hàng giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG
(đúng ý đồ của `overflow-x-auto` bọc ngoài) thay vì bóp chữ. */}
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
@ -467,6 +534,7 @@ export function KhkkDetailContent({
</tbody>
</table>
</div>
</>
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu SNAPSHOT tổng báo giá đưc chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
@ -515,8 +583,12 @@ export function KhkkDetailContent({
</tr>
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
{plan.dossierItems.map((d: KhkkDossierItemDto) => {
// [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…).
const files = dossierAttachments.get(d.id) ?? []
return (
<Fragment key={d.id}>
<tr className={cn('border-slate-100 hover:bg-slate-50/60', files.length === 0 && 'border-b last:border-0')}>
<td className="px-3 py-2 text-slate-600">{orDash(DOSSIER_KIND_LABELS[d.kind])}</td>
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2">
@ -532,6 +604,21 @@ export function KhkkDetailContent({
{isDraft && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `<label>` bọc input
ẩn thay vì `<button>`+ref: mỗi dòng cần một ô chọn file riêng,
dùng ref thì phải nuôi N ref cho N dòng. */}
<label
title="Đính kèm file cho căn cứ này (shopdrawing, mẫu vật liệu…)"
className="cursor-pointer rounded px-1.5 py-1 text-teal-600 hover:bg-teal-50"
>
<Upload className="h-3.5 w-3.5" />
<input
type="file"
multiple
className="hidden"
onChange={(e) => onPickFiles(e, d.id)}
/>
</label>
<button
type="button"
onClick={() => setItemForm({
@ -555,7 +642,46 @@ export function KhkkDetailContent({
</td>
)}
</tr>
))}
{/* [S175 · O4] Dòng-con liệt kê file của căn cứ. Hiện CẢ KHI read-only —
người duyệt cần xem được shopdrawing, chỉ không được thêm/xoá. */}
{files.length > 0 && (
<tr className="border-b border-slate-100 last:border-0">
<td colSpan={isDraft ? 6 : 5} className="bg-slate-50/40 px-3 pb-2">
<div className="flex flex-wrap gap-1.5 pl-3">
{files.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px]"
>
<Paperclip className="h-3 w-3 shrink-0 text-teal-500" aria-hidden />
<button
type="button"
onClick={() => download(a)}
title={`Tải xuống ${a.fileName} (${formatSize(a.fileSize)})`}
className="max-w-[16rem] truncate text-slate-700 hover:text-brand-700 hover:underline"
>
{a.fileName}
</button>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
{isDraft && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
title="Xóa file"
className="shrink-0 rounded text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>

View File

@ -178,6 +178,7 @@ export function KhkkListPanel({
page,
onPageChange,
selectedFinalize,
onOpenPe,
}: {
selectedId: string | null
onSelect: (id: string) => void
@ -193,6 +194,8 @@ export function KhkkListPanel({
page: number
onPageChange: (p: number) => void
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
/** [S175 · O6] Bấm card phiếu Duyệt NCC trên cây ⇒ host xem TẠI CHỖ (không rời màn). */
onOpenPe?: (id: string) => void
}) {
const { canPe } = usePipelineStages()
const term = search.trim()
@ -316,7 +319,7 @@ export function KhkkListPanel({
hideHeader
hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect, onOpenPe }}
/>
)}

View File

@ -24,7 +24,7 @@
// 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 { FileCheck, FileSignature, Lock } from 'lucide-react'
import { FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
@ -79,6 +79,7 @@ export function KhkkWorkListPanel({
onPickPlan,
selectedPeId,
selectedPlanId,
onEditPlan,
}: {
/** 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
@ -87,6 +88,9 @@ export function KhkkWorkListPanel({
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
}) {
const { can } = usePermission()
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
@ -114,15 +118,26 @@ export function KhkkWorkListPanel({
// 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<string, KhkkPeCandidateDto[]>()
// [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 key = pe.projectName ?? '(chưa gán dự án)'
const bucket = byProject.get(key)
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 byProject.set(key, [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:
@ -175,43 +190,57 @@ export function KhkkWorkListPanel({
Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p>
)}
{[...byProject.entries()].map(([projectName, items]) => (
{/* [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]) => (
<div key={projectName}>
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 px-3 py-1 backdrop-blur">
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
<div className="sticky top-0 z-20 flex items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 backdrop-blur">
<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] text-slate-500">{items.length}</span>
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
</div>
<ul className="divide-y divide-slate-100">
{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>
{[...byWorkItem.entries()].map(([workItemName, items]) => (
<div key={workItemName}>
<div className="sticky top-[1.375rem] z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 py-1 pl-6 pr-3 backdrop-blur">
<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>
</div>
<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>
@ -248,7 +277,7 @@ export function KhkkWorkListPanel({
)}
<ul className="divide-y divide-slate-100">
{planItems.map((k) => (
<li key={k.id}>
<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
@ -258,6 +287,8 @@ export function KhkkWorkListPanel({
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">
@ -280,6 +311,34 @@ export function KhkkWorkListPanel({
{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>