diff --git a/fe-admin/src/components/khkk/KhkkDetailContent.tsx b/fe-admin/src/components/khkk/KhkkDetailContent.tsx
index c247e9d..ba32896 100644
--- a/fe-admin/src/components/khkk/KhkkDetailContent.tsx
+++ b/fe-admin/src/components/khkk/KhkkDetailContent.tsx
@@ -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 (
{label}
-
+ {/* [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. */}
+
{children ?? (empty ? '—' : value)}
@@ -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) {
+ async function onPickFiles(e: React.ChangeEvent, 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()
+ 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 ? (
Phiếu nguồn chưa có NCC trúng thầu.
) : (
- // [S175 · B2 · P0] `table-fixed` + `
` + `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 (
+
` + `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ữ. */}
@@ -467,6 +534,7 @@ export function KhkkDetailContent({
+ >
)}
Giá tham chiếu là 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({
- {plan.dossierItems.map((d: KhkkDossierItemDto) => (
-
@@ -532,6 +604,21 @@ export function KhkkDetailContent({
{isDraft && (
+ {/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `
)}
- ))}
+ {/* [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 && (
+
diff --git a/fe-admin/src/components/khkk/KhkkListPanel.tsx b/fe-admin/src/components/khkk/KhkkListPanel.tsx
index a4373ca..2056b32 100644
--- a/fe-admin/src/components/khkk/KhkkListPanel.tsx
+++ b/fe-admin/src/components/khkk/KhkkListPanel.tsx
@@ -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 }}
/>
)}
diff --git a/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx b/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx
index e1086e5..303e41a 100644
--- a/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx
+++ b/fe-admin/src/components/khkk/KhkkWorkListPanel.tsx
@@ -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()
+ // [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>()
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) =>
+ [...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 đã có kế hoạch ký kết.
)}
- {[...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]) => (
))}
@@ -248,7 +277,7 @@ export function KhkkWorkListPanel({
)}
{planItems.map((k) => (
-
+
@@ -280,6 +311,34 @@ export function KhkkWorkListPanel({
{k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
+ {/* [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ẻ 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 (
+ 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',
+ )}
+ >
+
+
+ )
+ })()}
))}
diff --git a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx
index 2aa934a..bc0387b 100644
--- a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx
+++ b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx
@@ -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
// 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.
- stage2Options?: Pick
+ // [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
// [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 100vh−16rem là
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).
diff --git a/fe-admin/src/hooks/usePipelineStages.ts b/fe-admin/src/hooks/usePipelineStages.ts
index b1c2481..cff94a5 100644
--- a/fe-admin/src/hooks/usePipelineStages.ts
+++ b/fe-admin/src/hooks/usePipelineStages.ts
@@ -183,6 +183,13 @@ export type BuildStagesOptions = {
* cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
*/
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,
badgeLabel: PeDisplayStatusLabel[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
// 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`),
}
}),
}
diff --git a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx
index d52f48f..7588544 100644
--- a/fe-admin/src/pages/khkk/KhkkCreatePage.tsx
+++ b/fe-admin/src/pages/khkk/KhkkCreatePage.tsx
@@ -29,7 +29,7 @@
import { useState, type FormEvent, type ReactNode } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
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 { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
@@ -39,9 +39,12 @@ import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkListPanel } from '@/components/khkk/KhkkWorkListPanel'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
+import { cn } from '@/lib/cn'
+import { useAuth } from '@/contexts/AuthContext'
import {
KHKK_APPROVAL_GROUP_DEFAULT,
KHKK_APPROVAL_GROUP_LABELS,
+ KhkkPhase,
khkkGroupMenuLabel,
type CreateKhkkInput,
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
// cho form tạo. `null` ⇒ về form tạo. Đây là thứ thay cho việc `navigate('/khkk/:id')`.
const [viewPlanId, setViewPlanId] = useState(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.
const candidates = useQuery({
@@ -193,24 +199,54 @@ export function KhkkCreatePage() {
{ setViewPlanId(id); setEditPlan(false) }}
selectedPeId={purchaseEvaluationId || null}
selectedPlanId={viewPlanId}
+ onEditPlan={(id) => { setViewPlanId(id); setEditPlan(true) }}
/>
{/* (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 (
-
-
- Đang xem kế hoạch — chỉ xem, không sửa ở màn Thao tác.
+
+
+ {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.'}
- )}
+ )
+ })()}
{/* (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 && (
diff --git a/fe-admin/src/pages/khkk/KhkkListPage.tsx b/fe-admin/src/pages/khkk/KhkkListPage.tsx
index 1a3581f..7b28f4b 100644
--- a/fe-admin/src/pages/khkk/KhkkListPage.tsx
+++ b/fe-admin/src/pages/khkk/KhkkListPage.tsx
@@ -33,6 +33,10 @@ import { Button } from '@/components/ui/Button'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
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 { api } from '@/lib/api'
import {
@@ -92,10 +96,30 @@ export function KhkkListPage() {
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(`/purchase-evaluations/${selectedPeId}`)).data,
+ enabled: !!selectedPeId,
+ })
+
function setId(id: string | null) {
const next = new URLSearchParams(searchParams)
if (id == null) next.delete('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)
}
@@ -108,6 +132,16 @@ export function KhkkListPage() {
}
}
+ function selectPe(id: string) {
+ // Cùng luật breakpoint với `selectPlan`: `
{/* 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. */}
- {!selectedId && (
+ {!selectedId && !selectedPeId && (
)}
+
+ {/* [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 && (
+
+ Đang xem phiếu Duyệt NCC (giai đoạn 1) — chỉ xem, không thao tác.
+ 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ý kết
+
+
+ )}
+ {selectedPeId && peDetail.isLoading &&
Đang tải phiếu Duyệt NCC…
}
+ {selectedPeId && peDetail.isError && (
+
Không tải được phiếu Duyệt NCC (có thể bạn chưa có quyền xem).