From a0ccac3982af38626d5e8bd41efd10d77284dc82 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 5 Aug 2026 19:59:04 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Contract:=20UAT=20S174=20dot-D=20+?= =?UTF-8?q?=20C3=20=E2=80=94=20O6/O4/O7/O8=20+=202=20tong=20toan=20phieu?= =?UTF-8?q?=20(FE-only,=200-mig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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. ` 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 + {formatSize(a.fileSize)} + {isDraft && ( + + )} + + ))} + + + + )} + + ) + })} 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. */}