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 ( +
+
+
Tổng giá đề xuất
+
+ {formatVnd(sumProposed)} +
+
{plan.lines.length} dòng
+
+
+
Tổng giá duyệt
+
+ {approvedLines.length === 0 ? '—' : formatVnd(sumApproved)} +
+
+ {pending === 0 + ? 'đã chốt đủ mọi dòng' + : `còn ${pending}/${plan.lines.length} dòng chưa chốt giá`} +
+
+
+ ) + })()} + {/* [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ữ. */}
@@ -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) => ( - + {plan.dossierItems.map((d: KhkkDossierItemDto) => { + // [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…). + const files = dossierAttachments.get(d.id) ?? [] + return ( + + {orDash(DOSSIER_KIND_LABELS[d.kind])} {d.name} @@ -532,6 +604,21 @@ export function KhkkDetailContent({ {isDraft && (
+ {/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `
+ + + )} +
+ ) + })}
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]) => (
-
- +
+ + {projectName} - {items.length} + {countProject(byWorkItem)}
-
    - {items.map((pe) => ( -
  • - -
  • - ))} -
+ {[...byWorkItem.entries()].map(([workItemName, items]) => ( +
+
+ + {workItemName} + + {items.length} +
+
    + {items.map((pe) => ( +
  • + +
  • + ))} +
+
+ ))}
))} @@ -248,7 +277,7 @@ export function KhkkWorkListPanel({ )}
    {planItems.map((k) => ( -
  • +
  • + {/* [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ẻ + ) + })()}
  • ))}
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.'}
+ {readOnly && canEdit && ( + + )} + {!readOnly && ( + + )} - @@ -221,10 +257,11 @@ export function KhkkCreatePage() {
Không tìm thấy phiếu.
)} {viewPlan.data && ( - viewPlan.refetch()} /> + viewPlan.refetch()} /> )}
- )} + ) + })()} {/* (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. + +
+ )} + {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).
+ )} + {selectedPeId && peDetail.data && ( + setPeId(null)} + onDelete={() => setPeId(null)} + /> + )} + {selectedId && detail.isLoading &&
Đang tải…
} {selectedId && !detail.isLoading && !plan && (
Không tìm thấy phiếu.
@@ -185,11 +251,15 @@ export function KhkkListPage() { này là CÓ CHỦ ĐÍCH. */}