[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. // 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 // [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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
@ -123,7 +123,11 @@ function Field({
return ( return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}> <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="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)} {children ?? (empty ? '—' : value)}
</div> </div>
</div> </div>
@ -218,11 +222,16 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), 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({ const upload = useMutation({
mutationFn: async (file: File) => { mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan)) fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
if (dossierItemId) fd.append('dossierItemId', dossierItemId)
return api.post(`/contract-signing-plans/${id}/attachments`, fd, { return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }, 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 ?? []) const files = Array.from(e.target.files ?? [])
e.target.value = '' e.target.value = ''
for (const f of files) { 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. // 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 // 🧊 [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 // 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 ? ( {plan.lines.length === 0 ? (
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa NCC trúng thầu.</div> <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 {/* [S175 · O3 — ANH CHỐT] TỔNG đặt PHÍA TRÊN bảng, đúng 2 con số:
// 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 ① trục = CHỈ TỔNG TOÀN PHIẾU (anh bác phương án tổng-mỗi-NCC);
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ. ② 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"> <div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup> <colgroup>
@ -467,6 +534,7 @@ export function KhkkDetailContent({
</tbody> </tbody>
</table> </table>
</div> </div>
</>
)} )}
<div className="mt-3 text-xs text-muted-foreground"> <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. 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> </tr>
</thead> </thead>
<tbody> <tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => ( {plan.dossierItems.map((d: KhkkDossierItemDto) => {
<tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60"> // [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 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 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@ -532,6 +604,21 @@ export function KhkkDetailContent({
{isDraft && ( {isDraft && (
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <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 <button
type="button" type="button"
onClick={() => setItemForm({ onClick={() => setItemForm({
@ -555,7 +642,46 @@ export function KhkkDetailContent({
</td> </td>
)} )}
</tr> </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> </tbody>
</table> </table>
</div> </div>

View File

@ -178,6 +178,7 @@ export function KhkkListPanel({
page, page,
onPageChange, onPageChange,
selectedFinalize, selectedFinalize,
onOpenPe,
}: { }: {
selectedId: string | null selectedId: string | null
onSelect: (id: string) => void onSelect: (id: string) => void
@ -193,6 +194,8 @@ export function KhkkListPanel({
page: number page: number
onPageChange: (p: number) => void onPageChange: (p: number) => void
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null } 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 { canPe } = usePipelineStages()
const term = search.trim() const term = search.trim()
@ -316,7 +319,7 @@ export function KhkkListPanel({
hideHeader hideHeader
hideSearch hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" 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 // 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 { FileCheck, FileSignature, Lock } from 'lucide-react' import { FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
@ -79,6 +79,7 @@ export function KhkkWorkListPanel({
onPickPlan, onPickPlan,
selectedPeId, selectedPeId,
selectedPlanId, 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). */ /** 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 onPickPe: (peId: string) => void
@ -87,6 +88,9 @@ export function KhkkWorkListPanel({
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null 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 ⇒
* không vẽ bút chì (cột giữ đúng hình cũ). */
onEditPlan?: (planId: string) => void
}) { }) {
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ị).
@ -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ổ. // 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) 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ó // [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
// phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trướ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
const byProject = new Map<string, KhkkPeCandidateDto[]>() // 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) { for (const pe of awaiting) {
const key = pe.projectName ?? '(chưa gán dự án)' const pKey = pe.projectName ?? '(chưa gán dự án)'
const bucket = byProject.get(key) 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) 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 // [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:
@ -175,15 +190,27 @@ export function KhkkWorkListPanel({
Mọi phiếu đã duyệt đu đã kế hoạch kết. Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p> </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 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"> <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">
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <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} {projectName}
</span> </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> </div>
<ul className="divide-y divide-slate-100"> {[...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) => ( {items.map((pe) => (
<li key={pe.id}> <li key={pe.id}>
<button <button
@ -214,6 +241,8 @@ export function KhkkWorkListPanel({
</ul> </ul>
</div> </div>
))} ))}
</div>
))}
</Tier> </Tier>
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />
@ -248,7 +277,7 @@ export function KhkkWorkListPanel({
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{planItems.map((k) => ( {planItems.map((k) => (
<li key={k.id}> <li key={k.id} className="relative">
<button <button
type="button" type="button"
// 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện // 🔴 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( className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60', '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', 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"> <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)'} {k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</div> </div>
</button> </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> </li>
))} ))}
</ul> </ul>

View File

@ -96,7 +96,9 @@ export function PipelineTreePanel({
// [S171] Trang KHKK (GĐ2) tự vẽ thân folder GĐ2 = card phiếu của chính nó, và tự bắt // [S171] Trang KHKK (GĐ2) tự vẽ thân folder GĐ2 = card phiếu của chính nó, và tự bắt
// sự kiện mở phiếu (M-2: chọn vào panel 2 thay vì rời trang). Không truyền ⇒ cây giữ // sự kiện mở phiếu (M-2: chọn vào panel 2 thay vì rời trang). Không truyền ⇒ cây giữ
// NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4. // NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk'> // [S175 · O6] +`onOpenPe`: leaf GĐ1 (phiếu Duyệt NCC) cũng cho host bắt tại chỗ, đối
// xứng với `onOpenKhkk` của leaf GĐ2. Cả 2 đều OPT-IN nên 4 host không truyền giữ nguyên.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe'>
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN // [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là // cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau). // hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).

View File

@ -183,6 +183,13 @@ export type BuildStagesOptions = {
* cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3. * cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
*/ */
onOpenKhkk?: (id: string) => void onOpenKhkk?: (id: string) => void
/**
* [S175 · O6] Mirror ĐỐI XỨNG của `onOpenKhkk` cho leaf GĐ1 (phiếu Duyệt NCC).
* Anh chốt: *"bấm card PE trong cây ở màn KHKK phải GIỮ NGUYÊN màn KHKK — chỉ view là
* được, không re-direct về phiếu gốc"*. KHÔNG truyền ⇒ giữ NGUYÊN
* `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi.
*/
onOpenPe?: (id: string) => void
} }
/** /**
@ -214,9 +221,13 @@ export function usePipelineStages() {
secondary: p.tenGoiThau, secondary: p.tenGoiThau,
badgeLabel: PeDisplayStatusLabel[display], badgeLabel: PeDisplayStatusLabel[display],
badgeClass: PeDisplayStatusColor[display], badgeClass: PeDisplayStatusColor[display],
// [S175 · O6] Host truyền callback ⇒ xem TẠI CHỖ; không truyền ⇒ navigate
// như cũ. Ternary y khuôn leaf GĐ2 ở `:233` — 2 giai-đoạn cùng một luật.
// `expand=1` để mở overlay chi tiết — deep-link từ trang khác // `expand=1` để mở overlay chi tiết — deep-link từ trang khác
// chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg). // chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg).
onOpen: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`), onOpen: opts.onOpenPe
? () => opts.onOpenPe!(p.id)
: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),
} }
}), }),
} }

View File

@ -29,7 +29,7 @@
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileCheck, X } from 'lucide-react' import { FileCheck, Pencil, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -39,9 +39,12 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { import {
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
KhkkPhase,
khkkGroupMenuLabel, khkkGroupMenuLabel,
type CreateKhkkInput, type CreateKhkkInput,
type KhkkDetailDto, type KhkkDetailDto,
@ -81,6 +84,9 @@ export function KhkkCreatePage() {
// [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay // [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay
// cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`. // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
const [viewPlanId, setViewPlanId] = useState<string | null>(null) const [viewPlanId, setViewPlanId] = useState<string | null>(null)
// [S175 · O7] Bấm bút chì = mở thẳng chế-độ SỬA; bấm thân card = chỉ xem như cũ.
const [editPlan, setEditPlan] = useState(false)
const { user } = useAuth()
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần. // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
const candidates = useQuery({ const candidates = useQuery({
@ -193,24 +199,54 @@ export function KhkkCreatePage() {
<div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]"> <div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]">
<KhkkWorkListPanel <KhkkWorkListPanel
onPickPe={pickPe} onPickPe={pickPe}
onPickPlan={setViewPlanId} onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }}
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
/> />
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6"> <main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */} {/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
{viewPlanId && ( {viewPlanId && (() => {
// [S175 · O7] `readOnly` = KHÔNG bấm bút chì, HOẶC phiếu không đủ điều-kiện sửa.
// 🔴 Guard y hệt `KhkkListPage.tsx:147-150` — cùng một câu hỏi "ai được sửa phiếu
// này" thì phải cho cùng một câu trả lời ở mọi màn; lệch nhau là đẻ lỗ hổng đúng
// kiểu gotcha #82 (một màn khoá, màn kia hở).
// 🔸 Đây là rào HIỂN THỊ. Rào THẬT vẫn ở BE (`KeHoachKyKet.Update` + phase guard).
const p = viewPlan.data
const canEdit = !!p
&& (p.phase === KhkkPhase.DangSoanThao || p.phase === KhkkPhase.TraLai)
&& (!user?.id || p.drafterUserId === user.id)
const readOnly = !editPlan || !canEdit
return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2"> <div className={cn(
<span className="text-[12px] font-medium text-violet-800"> 'flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2',
Đang xem kế hoạch chỉ xem, không sửa màn Thao tác. readOnly ? 'border-violet-200 bg-violet-50' : 'border-emerald-300 bg-emerald-50',
)}>
<span className={cn('text-[12px] font-medium', readOnly ? 'text-violet-800' : 'text-emerald-800')}>
{readOnly
? (editPlan && !canEdit
? 'Kế hoạch này không sửa được (đã gửi duyệt / đã duyệt, hoặc bạn không phải người soạn) — chỉ xem.'
: 'Đang xem kế hoạch — chỉ xem. Bấm bút chì ở danh sách để sửa.')
: '✎ Đang SỬA kế hoạch — thay đổi lưu thẳng vào phiếu.'}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{readOnly && canEdit && (
<Button variant="outline" size="sm" onClick={() => setEditPlan(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Sửa
</Button>
)}
{!readOnly && (
<Button variant="outline" size="sm" onClick={() => setEditPlan(false)}>
Xong
</Button>
)}
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}> <Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
Mở trang đy đ Mở trang đy đ
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => setViewPlanId(null)}> <Button variant="ghost" size="sm" onClick={() => { setViewPlanId(null); setEditPlan(false) }}>
<X className="mr-1.5 h-3.5 w-3.5" /> <X className="mr-1.5 h-3.5 w-3.5" />
Về form tạo Về form tạo
</Button> </Button>
@ -221,10 +257,11 @@ export function KhkkCreatePage() {
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div> <div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)} )}
{viewPlan.data && ( {viewPlan.data && (
<KhkkDetailContent plan={viewPlan.data} readOnly onChanged={() => viewPlan.refetch()} /> <KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} />
)} )}
</div> </div>
)} )
})()}
{/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */} {/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
{!viewPlanId && !purchaseEvaluationId && ( {!viewPlanId && !purchaseEvaluationId && (

View File

@ -33,6 +33,10 @@ import { Button } from '@/components/ui/Button'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel' import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel' import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
// [S175 · O6] Xem phiếu Duyệt NCC tại chỗ — dùng LẠI đúng renderer của màn PE thay vì dựng
// bản rút gọn thứ hai (2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia).
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { type PeDetailBundle } from '@/types/purchaseEvaluation'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { import {
@ -92,10 +96,30 @@ export function KhkkListPage() {
enabled: !!selectedId, enabled: !!selectedId,
}) })
// [S175 · O6] Phiếu Duyệt NCC đang XEM TẠI CHỖ. Anh chốt: bấm card PE trên cây thì
// GIỮ NGUYÊN màn KHKK, chỉ xem, KHÔNG re-direct về phiếu gốc.
// 🔴 `pe` và `id` LOẠI TRỪ NHAU: panel 2 chỉ vẽ được một thứ, để cả hai thì lần chọn sau
// không xoá được lần trước và người dùng kẹt ở phiếu cũ.
const selectedPeId = searchParams.get('pe')
const peDetail = useQuery({
queryKey: ['pe-detail', selectedPeId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedPeId}`)).data,
enabled: !!selectedPeId,
})
function setId(id: string | null) { function setId(id: string | null) {
const next = new URLSearchParams(searchParams) const next = new URLSearchParams(searchParams)
if (id == null) next.delete('id') if (id == null) next.delete('id')
else next.set('id', id) else next.set('id', id)
next.delete('pe')
setSearchParams(next)
}
function setPeId(id: string | null) {
const next = new URLSearchParams(searchParams)
if (id == null) next.delete('pe')
else next.set('pe', id)
next.delete('id')
setSearchParams(next) setSearchParams(next)
} }
@ -108,6 +132,16 @@ export function KhkkListPage() {
} }
} }
function selectPe(id: string) {
// Cùng luật breakpoint với `selectPlan`: `<lg` KHÔNG có panel 2 nên xem tại chỗ là bất
// khả — rơi về đúng đường cũ (trang Duyệt NCC + overlay) thay vì bấm mà không thấy gì.
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setPeId(id)
} else {
navigate(`/purchase-evaluations?id=${id}&expand=1`)
}
}
const plan = detail.data const plan = detail.data
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem. // [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
const readOnly = const readOnly =
@ -155,12 +189,13 @@ export function KhkkListPage() {
? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName } ? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName }
: undefined : undefined
} }
onOpenPe={selectPe}
/> />
{/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy; {/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy;
thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */} thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && ( {!selectedId && !selectedPeId && (
<EmptyState <EmptyState
icon={FileCheck} icon={FileCheck}
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'} title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
@ -169,6 +204,37 @@ export function KhkkListPage() {
: 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'} : 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'}
/> />
)} )}
{/* [S175 · O6] Xem phiếu Duyệt NCC TẠI CHỖ — `readOnly` + `mode="workspace"` để
chắc chắn không có đường sửa/duyệt nào lọt ra ở màn KHKK (anh chốt "chỉ view").
Băng chỉ-đường ở trên vì panel 2 lúc này KHÔNG còn nói về kế hoạch ký kết nữa —
không nói rõ thì người dùng tưởng mình đang xem KHKK. */}
{selectedPeId && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-md border border-brand-200 bg-brand-50 px-3 py-2 text-[12px] text-brand-800">
<span>Đang xem <strong>phiếu Duyệt NCC</strong> (giai đoạn 1) chỉ xem, không thao tác.</span>
<button
type="button"
onClick={() => setPeId(null)}
className="shrink-0 rounded border border-brand-300 bg-white px-2 py-0.5 font-semibold text-brand-700 transition hover:bg-brand-100"
>
Về kế hoạch kết
</button>
</div>
)}
{selectedPeId && peDetail.isLoading && <div className="text-sm text-slate-500">Đang tải phiếu Duyệt NCC</div>}
{selectedPeId && peDetail.isError && (
<div className="text-sm text-red-600">Không tải đưc phiếu Duyệt NCC ( thể bạn chưa quyền xem).</div>
)}
{selectedPeId && peDetail.data && (
<PeDetailTabs
evaluation={peDetail.data}
readOnly
mode="workspace"
onBack={() => setPeId(null)}
onDelete={() => setPeId(null)}
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>} {selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !plan && ( {selectedId && !detail.isLoading && !plan && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div> <div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
@ -185,9 +251,13 @@ export function KhkkListPage() {
này là CÓ CHỦ ĐÍCH. */} này là CÓ CHỦ ĐÍCH. */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block"> <aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && ( {!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400"> <div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">
<X className="mx-auto mb-2 h-5 w-5" /> <X className="mx-auto mb-2 h-5 w-5" />
{deletedView {/* [S175 · O6] Đang xem PE ⇒ NÓI RÕ quy trình duyệt của nó nằm ở màn khác,
thay vì để nguyên câu "sẽ hiện khi chọn phiếu" (sai: phiếu ĐÃ chọn rồi). */}
{selectedPeId
? 'Đang xem phiếu Duyệt NCC — quy trình duyệt của phiếu này nằm ở màn Duyệt NCC.'
: deletedView
? 'Phiếu đã xóa không còn quy trình duyệt.' ? 'Phiếu đã xóa không còn quy trình duyệt.'
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'} : 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
</div> </div>

View File

@ -18,7 +18,7 @@
// Không dựng workspace riêng cho việc này. // 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 // [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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
@ -123,7 +123,11 @@ function Field({
return ( return (
<div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}> <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="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)} {children ?? (empty ? '—' : value)}
</div> </div>
</div> </div>
@ -218,11 +222,16 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), 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({ const upload = useMutation({
mutationFn: async (file: File) => { mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan)) fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan))
if (dossierItemId) fd.append('dossierItemId', dossierItemId)
return api.post(`/contract-signing-plans/${id}/attachments`, fd, { return api.post(`/contract-signing-plans/${id}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }, 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 ?? []) const files = Array.from(e.target.files ?? [])
e.target.value = '' e.target.value = ''
for (const f of files) { 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. // 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 // 🧊 [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 // 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 ? ( {plan.lines.length === 0 ? (
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa NCC trúng thầu.</div> <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 {/* [S175 · O3 — ANH CHỐT] TỔNG đặt PHÍA TRÊN bảng, đúng 2 con số:
// 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 ① trục = CHỈ TỔNG TOÀN PHIẾU (anh bác phương án tổng-mỗi-NCC);
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ. ② 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"> <div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup> <colgroup>
@ -467,6 +534,7 @@ export function KhkkDetailContent({
</tbody> </tbody>
</table> </table>
</div> </div>
</>
)} )}
<div className="mt-3 text-xs text-muted-foreground"> <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. 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> </tr>
</thead> </thead>
<tbody> <tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => ( {plan.dossierItems.map((d: KhkkDossierItemDto) => {
<tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60"> // [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 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 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@ -532,6 +604,21 @@ export function KhkkDetailContent({
{isDraft && ( {isDraft && (
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <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 <button
type="button" type="button"
onClick={() => setItemForm({ onClick={() => setItemForm({
@ -555,7 +642,46 @@ export function KhkkDetailContent({
</td> </td>
)} )}
</tr> </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> </tbody>
</table> </table>
</div> </div>

View File

@ -178,6 +178,7 @@ export function KhkkListPanel({
page, page,
onPageChange, onPageChange,
selectedFinalize, selectedFinalize,
onOpenPe,
}: { }: {
selectedId: string | null selectedId: string | null
onSelect: (id: string) => void onSelect: (id: string) => void
@ -193,6 +194,8 @@ export function KhkkListPanel({
page: number page: number
onPageChange: (p: number) => void onPageChange: (p: number) => void
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null } 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 { canPe } = usePipelineStages()
const term = search.trim() const term = search.trim()
@ -316,7 +319,7 @@ export function KhkkListPanel({
hideHeader hideHeader
hideSearch hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" 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 // 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 { FileCheck, FileSignature, Lock } from 'lucide-react' import { FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission' import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
@ -79,6 +79,7 @@ export function KhkkWorkListPanel({
onPickPlan, onPickPlan,
selectedPeId, selectedPeId,
selectedPlanId, 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). */ /** 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 onPickPe: (peId: string) => void
@ -87,6 +88,9 @@ export function KhkkWorkListPanel({
onPickPlan: (planId: string) => void onPickPlan: (planId: string) => void
selectedPeId?: string | null 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 ⇒
* không vẽ bút chì (cột giữ đúng hình cũ). */
onEditPlan?: (planId: string) => void
}) { }) {
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ị).
@ -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ổ. // 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) 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ó // [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
// phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trướ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
const byProject = new Map<string, KhkkPeCandidateDto[]>() // 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) { for (const pe of awaiting) {
const key = pe.projectName ?? '(chưa gán dự án)' const pKey = pe.projectName ?? '(chưa gán dự án)'
const bucket = byProject.get(key) 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) 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 // [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:
@ -175,15 +190,27 @@ export function KhkkWorkListPanel({
Mọi phiếu đã duyệt đu đã kế hoạch kết. Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p> </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 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"> <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">
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <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} {projectName}
</span> </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> </div>
<ul className="divide-y divide-slate-100"> {[...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) => ( {items.map((pe) => (
<li key={pe.id}> <li key={pe.id}>
<button <button
@ -214,6 +241,8 @@ export function KhkkWorkListPanel({
</ul> </ul>
</div> </div>
))} ))}
</div>
))}
</Tier> </Tier>
<div className="shrink-0 border-t-2 border-slate-200" /> <div className="shrink-0 border-t-2 border-slate-200" />
@ -248,7 +277,7 @@ export function KhkkWorkListPanel({
)} )}
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{planItems.map((k) => ( {planItems.map((k) => (
<li key={k.id}> <li key={k.id} className="relative">
<button <button
type="button" type="button"
// 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện // 🔴 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( className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60', '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', 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"> <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)'} {k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</div> </div>
</button> </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> </li>
))} ))}
</ul> </ul>

View File

@ -96,7 +96,9 @@ export function PipelineTreePanel({
// [S171] Trang KHKK (GĐ2) tự vẽ thân folder GĐ2 = card phiếu của chính nó, và tự bắt // [S171] Trang KHKK (GĐ2) tự vẽ thân folder GĐ2 = card phiếu của chính nó, và tự bắt
// sự kiện mở phiếu (M-2: chọn vào panel 2 thay vì rời trang). Không truyền ⇒ cây giữ // sự kiện mở phiếu (M-2: chọn vào panel 2 thay vì rời trang). Không truyền ⇒ cây giữ
// NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4. // NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk'> // [S175 · O6] +`onOpenPe`: leaf GĐ1 (phiếu Duyệt NCC) cũng cho host bắt tại chỗ, đối
// xứng với `onOpenKhkk` của leaf GĐ2. Cả 2 đều OPT-IN nên 4 host không truyền giữ nguyên.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe'>
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN // [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là // cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau). // hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).

View File

@ -183,6 +183,13 @@ export type BuildStagesOptions = {
* cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3. * cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
*/ */
onOpenKhkk?: (id: string) => void onOpenKhkk?: (id: string) => void
/**
* [S175 · O6] Mirror ĐỐI XỨNG của `onOpenKhkk` cho leaf GĐ1 (phiếu Duyệt NCC).
* Anh chốt: *"bấm card PE trong cây ở màn KHKK phải GIỮ NGUYÊN màn KHKK — chỉ view là
* được, không re-direct về phiếu gốc"*. KHÔNG truyền ⇒ giữ NGUYÊN
* `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi.
*/
onOpenPe?: (id: string) => void
} }
/** /**
@ -214,9 +221,13 @@ export function usePipelineStages() {
secondary: p.tenGoiThau, secondary: p.tenGoiThau,
badgeLabel: PeDisplayStatusLabel[display], badgeLabel: PeDisplayStatusLabel[display],
badgeClass: PeDisplayStatusColor[display], badgeClass: PeDisplayStatusColor[display],
// [S175 · O6] Host truyền callback ⇒ xem TẠI CHỖ; không truyền ⇒ navigate
// như cũ. Ternary y khuôn leaf GĐ2 ở `:233` — 2 giai-đoạn cùng một luật.
// `expand=1` để mở overlay chi tiết — deep-link từ trang khác // `expand=1` để mở overlay chi tiết — deep-link từ trang khác
// chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg). // chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg).
onOpen: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`), onOpen: opts.onOpenPe
? () => opts.onOpenPe!(p.id)
: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),
} }
}), }),
} }

View File

@ -29,7 +29,7 @@
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileCheck, X } from 'lucide-react' import { FileCheck, Pencil, X } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -39,9 +39,12 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel' import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { import {
KHKK_APPROVAL_GROUP_DEFAULT, KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS, KHKK_APPROVAL_GROUP_LABELS,
KhkkPhase,
khkkGroupMenuLabel, khkkGroupMenuLabel,
type CreateKhkkInput, type CreateKhkkInput,
type KhkkDetailDto, type KhkkDetailDto,
@ -81,6 +84,9 @@ export function KhkkCreatePage() {
// [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay // [S174] Phiếu KHKK đang XEM ở panel 2. Có giá trị ⇒ panel 2 hiện phiếu (read-only) thay
// cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`. // cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
const [viewPlanId, setViewPlanId] = useState<string | null>(null) const [viewPlanId, setViewPlanId] = useState<string | null>(null)
// [S175 · O7] Bấm bút chì = mở thẳng chế-độ SỬA; bấm thân card = chỉ xem như cũ.
const [editPlan, setEditPlan] = useState(false)
const { user } = useAuth()
// Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần. // Cùng query key với `KhkkWorkListPanel` ⇒ dùng CHUNG cache, không gọi mạng 2 lần.
const candidates = useQuery({ const candidates = useQuery({
@ -193,24 +199,54 @@ export function KhkkCreatePage() {
<div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]"> <div className="grid flex-1 grid-cols-1 grid-rows-[minmax(0,14rem)_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_1fr] lg:grid-rows-1 xl:grid-cols-[320px_1fr]">
<KhkkWorkListPanel <KhkkWorkListPanel
onPickPe={pickPe} onPickPe={pickPe}
onPickPlan={setViewPlanId} onPickPlan={(id) => { setViewPlanId(id); setEditPlan(false) }}
selectedPeId={purchaseEvaluationId || null} selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId} selectedPlanId={viewPlanId}
onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
/> />
<main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6"> <main className="min-h-0 overflow-y-auto bg-slate-50 p-4 lg:p-6">
{/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */} {/* (a) Đang XEM một kế hoạch — read-only, có đường quay về form tạo. */}
{viewPlanId && ( {viewPlanId && (() => {
// [S175 · O7] `readOnly` = KHÔNG bấm bút chì, HOẶC phiếu không đủ điều-kiện sửa.
// 🔴 Guard y hệt `KhkkListPage.tsx:147-150` — cùng một câu hỏi "ai được sửa phiếu
// này" thì phải cho cùng một câu trả lời ở mọi màn; lệch nhau là đẻ lỗ hổng đúng
// kiểu gotcha #82 (một màn khoá, màn kia hở).
// 🔸 Đây là rào HIỂN THỊ. Rào THẬT vẫn ở BE (`KeHoachKyKet.Update` + phase guard).
const p = viewPlan.data
const canEdit = !!p
&& (p.phase === KhkkPhase.DangSoanThao || p.phase === KhkkPhase.TraLai)
&& (!user?.id || p.drafterUserId === user.id)
const readOnly = !editPlan || !canEdit
return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50 px-4 py-2"> <div className={cn(
<span className="text-[12px] font-medium text-violet-800"> 'flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2',
Đang xem kế hoạch chỉ xem, không sửa màn Thao tác. readOnly ? 'border-violet-200 bg-violet-50' : 'border-emerald-300 bg-emerald-50',
)}>
<span className={cn('text-[12px] font-medium', readOnly ? 'text-violet-800' : 'text-emerald-800')}>
{readOnly
? (editPlan && !canEdit
? 'Kế hoạch này không sửa được (đã gửi duyệt / đã duyệt, hoặc bạn không phải người soạn) — chỉ xem.'
: 'Đang xem kế hoạch — chỉ xem. Bấm bút chì ở danh sách để sửa.')
: '✎ Đang SỬA kế hoạch — thay đổi lưu thẳng vào phiếu.'}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{readOnly && canEdit && (
<Button variant="outline" size="sm" onClick={() => setEditPlan(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Sửa
</Button>
)}
{!readOnly && (
<Button variant="outline" size="sm" onClick={() => setEditPlan(false)}>
Xong
</Button>
)}
<Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}> <Button variant="outline" size="sm" onClick={() => navigate(`/khkk/${viewPlanId}`)}>
Mở trang đy đ Mở trang đy đ
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => setViewPlanId(null)}> <Button variant="ghost" size="sm" onClick={() => { setViewPlanId(null); setEditPlan(false) }}>
<X className="mr-1.5 h-3.5 w-3.5" /> <X className="mr-1.5 h-3.5 w-3.5" />
Về form tạo Về form tạo
</Button> </Button>
@ -221,10 +257,11 @@ export function KhkkCreatePage() {
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div> <div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)} )}
{viewPlan.data && ( {viewPlan.data && (
<KhkkDetailContent plan={viewPlan.data} readOnly onChanged={() => viewPlan.refetch()} /> <KhkkDetailContent plan={viewPlan.data} readOnly={readOnly} onChanged={() => viewPlan.refetch()} />
)} )}
</div> </div>
)} )
})()}
{/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */} {/* (b) Chưa chọn gì — nói rõ phải bấm ở đâu, thay vì để trống một mảng trắng. */}
{!viewPlanId && !purchaseEvaluationId && ( {!viewPlanId && !purchaseEvaluationId && (

View File

@ -33,6 +33,10 @@ import { Button } from '@/components/ui/Button'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel' import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent' import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel' import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
// [S175 · O6] Xem phiếu Duyệt NCC tại chỗ — dùng LẠI đúng renderer của màn PE thay vì dựng
// bản rút gọn thứ hai (2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia).
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { type PeDetailBundle } from '@/types/purchaseEvaluation'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { import {
@ -92,10 +96,30 @@ export function KhkkListPage() {
enabled: !!selectedId, enabled: !!selectedId,
}) })
// [S175 · O6] Phiếu Duyệt NCC đang XEM TẠI CHỖ. Anh chốt: bấm card PE trên cây thì
// GIỮ NGUYÊN màn KHKK, chỉ xem, KHÔNG re-direct về phiếu gốc.
// 🔴 `pe` và `id` LOẠI TRỪ NHAU: panel 2 chỉ vẽ được một thứ, để cả hai thì lần chọn sau
// không xoá được lần trước và người dùng kẹt ở phiếu cũ.
const selectedPeId = searchParams.get('pe')
const peDetail = useQuery({
queryKey: ['pe-detail', selectedPeId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedPeId}`)).data,
enabled: !!selectedPeId,
})
function setId(id: string | null) { function setId(id: string | null) {
const next = new URLSearchParams(searchParams) const next = new URLSearchParams(searchParams)
if (id == null) next.delete('id') if (id == null) next.delete('id')
else next.set('id', id) else next.set('id', id)
next.delete('pe')
setSearchParams(next)
}
function setPeId(id: string | null) {
const next = new URLSearchParams(searchParams)
if (id == null) next.delete('pe')
else next.set('pe', id)
next.delete('id')
setSearchParams(next) setSearchParams(next)
} }
@ -108,6 +132,16 @@ export function KhkkListPage() {
} }
} }
function selectPe(id: string) {
// Cùng luật breakpoint với `selectPlan`: `<lg` KHÔNG có panel 2 nên xem tại chỗ là bất
// khả — rơi về đúng đường cũ (trang Duyệt NCC + overlay) thay vì bấm mà không thấy gì.
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setPeId(id)
} else {
navigate(`/purchase-evaluations?id=${id}&expand=1`)
}
}
const plan = detail.data const plan = detail.data
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem. // [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
const readOnly = const readOnly =
@ -155,12 +189,13 @@ export function KhkkListPage() {
? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName } ? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName }
: undefined : undefined
} }
onOpenPe={selectPe}
/> />
{/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy; {/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy;
thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */} thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && ( {!selectedId && !selectedPeId && (
<EmptyState <EmptyState
icon={FileCheck} icon={FileCheck}
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'} title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
@ -169,6 +204,37 @@ export function KhkkListPage() {
: 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'} : 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'}
/> />
)} )}
{/* [S175 · O6] Xem phiếu Duyệt NCC TẠI CHỖ — `readOnly` + `mode="workspace"` để
chắc chắn không có đường sửa/duyệt nào lọt ra ở màn KHKK (anh chốt "chỉ view").
Băng chỉ-đường ở trên vì panel 2 lúc này KHÔNG còn nói về kế hoạch ký kết nữa —
không nói rõ thì người dùng tưởng mình đang xem KHKK. */}
{selectedPeId && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-md border border-brand-200 bg-brand-50 px-3 py-2 text-[12px] text-brand-800">
<span>Đang xem <strong>phiếu Duyệt NCC</strong> (giai đoạn 1) chỉ xem, không thao tác.</span>
<button
type="button"
onClick={() => setPeId(null)}
className="shrink-0 rounded border border-brand-300 bg-white px-2 py-0.5 font-semibold text-brand-700 transition hover:bg-brand-100"
>
Về kế hoạch kết
</button>
</div>
)}
{selectedPeId && peDetail.isLoading && <div className="text-sm text-slate-500">Đang tải phiếu Duyệt NCC</div>}
{selectedPeId && peDetail.isError && (
<div className="text-sm text-red-600">Không tải đưc phiếu Duyệt NCC ( thể bạn chưa quyền xem).</div>
)}
{selectedPeId && peDetail.data && (
<PeDetailTabs
evaluation={peDetail.data}
readOnly
mode="workspace"
onBack={() => setPeId(null)}
onDelete={() => setPeId(null)}
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>} {selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !plan && ( {selectedId && !detail.isLoading && !plan && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div> <div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
@ -185,9 +251,13 @@ export function KhkkListPage() {
này là CÓ CHỦ ĐÍCH. */} này là CÓ CHỦ ĐÍCH. */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block"> <aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && ( {!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400"> <div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">
<X className="mx-auto mb-2 h-5 w-5" /> <X className="mx-auto mb-2 h-5 w-5" />
{deletedView {/* [S175 · O6] Đang xem PE ⇒ NÓI RÕ quy trình duyệt của nó nằm ở màn khác,
thay vì để nguyên câu "sẽ hiện khi chọn phiếu" (sai: phiếu ĐÃ chọn rồi). */}
{selectedPeId
? 'Đang xem phiếu Duyệt NCC — quy trình duyệt của phiếu này nằm ở màn Duyệt NCC.'
: deletedView
? 'Phiếu đã xóa không còn quy trình duyệt.' ? 'Phiếu đã xóa không còn quy trình duyệt.'
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'} : 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
</div> </div>