diff --git a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx index fdaa578..5d90186 100644 --- a/fe-admin/src/components/pipeline/PipelineTreePanel.tsx +++ b/fe-admin/src/components/pipeline/PipelineTreePanel.tsx @@ -108,7 +108,10 @@ export function PipelineTreePanel({ // NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4. // [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 + // [S190 · W2 YC-029] +`onOpenContract` (leaf GĐ3 — khép kín bộ ba GĐ1/GĐ2/GĐ3) và + // +`stage3TypeFilter` (lọc leaf GĐ3 theo loại HĐ, nguồn = Select loại ở host `my-contracts`). + // GIỮ TÊN prop `stage2Options` dù nay mang cả tuỳ-chọn GĐ3: đổi tên = chạm 7 host, ngoài wave. + 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 cff94a5..79b25f7 100644 --- a/fe-admin/src/hooks/usePipelineStages.ts +++ b/fe-admin/src/hooks/usePipelineStages.ts @@ -190,6 +190,22 @@ export type BuildStagesOptions = { * `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi. */ onOpenPe?: (id: string) => void + /** + * [S190 · W2 YC-029] Bộ ba khép kín với `onOpenKhkk` (GĐ2) / `onOpenPe` (GĐ1): leaf GĐ3 (HĐ) + * cho HOST bắt tại chỗ. Màn `my-contracts` sau hoán-đổi đặt cây ở panel-1 TRÁI ⇒ bấm leaf phải + * chọn HĐ vào panel-2 (URL ở lại `/my-contracts`), không rời trang. + * không truyền ⇒ giữ nguyên `navigate('/contracts/:id')` — 6 host còn lại (kể cả fe-admin + * `ContractsListPage.tsx:265`) 0 đổi hành vi. + */ + onOpenContract?: (id: string) => void + /** + * [S190 · W2 YC-029] Lọc leaf GĐ3 theo `ContractType` (1..7) — nguồn là thanh `Select` loại HĐ + * ở đầu panel cây `my-contracts` (Q2 nhánh-đã-chọn). + * không truyền ⇒ giữ nguyên cây đủ MỌI loại HĐ — 6 host còn lại 0 đổi hành vi. + * 🔴 HĐ nằm NGOÀI cửa sổ index 200 KHÔNG suy được loại ⇒ vẫn hiện dù filter bật; lý-do ghi tại + * chỗ dùng (`passStage3Type`). + */ + stage3TypeFilter?: number } /** @@ -365,7 +381,13 @@ export function usePipelineStages() { peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau ?? null - if (linkage.canContract) { + // [S190 W2] Vế lọc loại HĐ — CHỈ chạy khi host bật `stage3TypeFilter` (opt-in). + // 🔴 QUYẾT ĐỊNH "THÀ THỪA CÒN HƠN THIẾU": `c` vắng = HĐ ngoài cửa sổ index 200 (xem + // `primary` ngay dưới) ⇒ KHÔNG có `c.type` để phân loại. Ẩn nó đi khi filter bật là làm + // HĐ biến mất IM LẶNG — đúng cái bệnh F-3 S162 đã vá bằng nhãn "(ngoài chỉ mục 200)". + // Vậy nên `!c` được cho QUA: người dùng thấy thừa 1 leaf còn hơn mất trắng 1 HĐ. + const passStage3Type = !opts.stage3TypeFilter || !c || c.type === opts.stage3TypeFilter + if (linkage.canContract && passStage3Type) { stage3Leaves.push({ id: contractId, // [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn @@ -374,7 +396,11 @@ export function usePipelineStages() { secondary: c?.tenHopDong ?? fallbackName, badgeLabel: c ? ContractPhaseLabel[c.phase] : null, badgeClass: c ? ContractPhaseColor[c.phase] : null, - onOpen: () => navigate(`/contracts/${contractId}`), + // [S190 W2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ. + // Ternary y khuôn leaf GĐ1 `:228-230` / GĐ2 `:244` — 3 giai-đoạn cùng một luật. + onOpen: opts.onOpenContract + ? () => opts.onOpenContract!(contractId) + : () => navigate(`/contracts/${contractId}`), }) } if (linkage.canHardCopy && c?.hasSealedCopy) { diff --git a/fe-user/src/components/pipeline/PipelineTreePanel.tsx b/fe-user/src/components/pipeline/PipelineTreePanel.tsx index fdaa578..5d90186 100644 --- a/fe-user/src/components/pipeline/PipelineTreePanel.tsx +++ b/fe-user/src/components/pipeline/PipelineTreePanel.tsx @@ -108,7 +108,10 @@ export function PipelineTreePanel({ // NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4. // [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 + // [S190 · W2 YC-029] +`onOpenContract` (leaf GĐ3 — khép kín bộ ba GĐ1/GĐ2/GĐ3) và + // +`stage3TypeFilter` (lọc leaf GĐ3 theo loại HĐ, nguồn = Select loại ở host `my-contracts`). + // GIỮ TÊN prop `stage2Options` dù nay mang cả tuỳ-chọn GĐ3: đổi tên = chạm 7 host, ngoài wave. + 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-user/src/hooks/usePipelineStages.ts b/fe-user/src/hooks/usePipelineStages.ts index cff94a5..79b25f7 100644 --- a/fe-user/src/hooks/usePipelineStages.ts +++ b/fe-user/src/hooks/usePipelineStages.ts @@ -190,6 +190,22 @@ export type BuildStagesOptions = { * `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi. */ onOpenPe?: (id: string) => void + /** + * [S190 · W2 YC-029] Bộ ba khép kín với `onOpenKhkk` (GĐ2) / `onOpenPe` (GĐ1): leaf GĐ3 (HĐ) + * cho HOST bắt tại chỗ. Màn `my-contracts` sau hoán-đổi đặt cây ở panel-1 TRÁI ⇒ bấm leaf phải + * chọn HĐ vào panel-2 (URL ở lại `/my-contracts`), không rời trang. + * không truyền ⇒ giữ nguyên `navigate('/contracts/:id')` — 6 host còn lại (kể cả fe-admin + * `ContractsListPage.tsx:265`) 0 đổi hành vi. + */ + onOpenContract?: (id: string) => void + /** + * [S190 · W2 YC-029] Lọc leaf GĐ3 theo `ContractType` (1..7) — nguồn là thanh `Select` loại HĐ + * ở đầu panel cây `my-contracts` (Q2 nhánh-đã-chọn). + * không truyền ⇒ giữ nguyên cây đủ MỌI loại HĐ — 6 host còn lại 0 đổi hành vi. + * 🔴 HĐ nằm NGOÀI cửa sổ index 200 KHÔNG suy được loại ⇒ vẫn hiện dù filter bật; lý-do ghi tại + * chỗ dùng (`passStage3Type`). + */ + stage3TypeFilter?: number } /** @@ -365,7 +381,13 @@ export function usePipelineStages() { peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau ?? null - if (linkage.canContract) { + // [S190 W2] Vế lọc loại HĐ — CHỈ chạy khi host bật `stage3TypeFilter` (opt-in). + // 🔴 QUYẾT ĐỊNH "THÀ THỪA CÒN HƠN THIẾU": `c` vắng = HĐ ngoài cửa sổ index 200 (xem + // `primary` ngay dưới) ⇒ KHÔNG có `c.type` để phân loại. Ẩn nó đi khi filter bật là làm + // HĐ biến mất IM LẶNG — đúng cái bệnh F-3 S162 đã vá bằng nhãn "(ngoài chỉ mục 200)". + // Vậy nên `!c` được cho QUA: người dùng thấy thừa 1 leaf còn hơn mất trắng 1 HĐ. + const passStage3Type = !opts.stage3TypeFilter || !c || c.type === opts.stage3TypeFilter + if (linkage.canContract && passStage3Type) { stage3Leaves.push({ id: contractId, // [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn @@ -374,7 +396,11 @@ export function usePipelineStages() { secondary: c?.tenHopDong ?? fallbackName, badgeLabel: c ? ContractPhaseLabel[c.phase] : null, badgeClass: c ? ContractPhaseColor[c.phase] : null, - onOpen: () => navigate(`/contracts/${contractId}`), + // [S190 W2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ. + // Ternary y khuôn leaf GĐ1 `:228-230` / GĐ2 `:244` — 3 giai-đoạn cùng một luật. + onOpen: opts.onOpenContract + ? () => opts.onOpenContract!(contractId) + : () => navigate(`/contracts/${contractId}`), }) } if (linkage.canHardCopy && c?.hasSealedCopy) { diff --git a/fe-user/src/pages/contracts/MyContractsPage.tsx b/fe-user/src/pages/contracts/MyContractsPage.tsx index cd03de6..9b5048d 100644 --- a/fe-user/src/pages/contracts/MyContractsPage.tsx +++ b/fe-user/src/pages/contracts/MyContractsPage.tsx @@ -1,10 +1,21 @@ -// 3-panel "Danh sách HĐ" — Panel 1 (list compact, click chọn) | Panel 2 -// (detail content) | Panel 3 (workflow + lịch sử duyệt). Selected HĐ giữ qua -// URL `?id=` để bookmarkable + back/forward navigation work. +// 3-panel "Danh sách HĐ" — [S190 · W2 YC-029 Đ1] HOÁN ĐỔI 2 khung theo ảnh anh gửi +// ("Bỏ phần này sang bên này nhé" — mũi tên từ cây PHẢI chỉ sang danh-sách TRÁI): +// Panel 1 TRÁI 400px = CÂY TOÀN TRÌNH (GĐ3) + Select loại HĐ ← trước ở Panel 3 +// Panel 2 GIỮA = detail content + quy trình/lịch sử duyệt của HĐ đang chọn +// Panel 3 PHẢI 360px = DANH SÁCH HĐ (search + empty-state) ← trước ở Panel 1 +// Selected HĐ vẫn giữ qua URL `?id=` để bookmarkable + back/forward navigation work. // -// Mobile fallback (< lg): hiển thị Panel 1 list, click row → fullpage -// /contracts/:id (giữ flow cũ, không cố nhồi 3 panel vào màn hình hẹp). -import { useMemo } from 'react' +// 🔴 CHỈ ĐỔI CHỖ, KHÔNG XOÁ KHỐI NÀO (Q1): +// • Danh-sách phải ở lại: cây chỉ dựng từ phiếu Duyệt NCC ⇒ HĐ tạo TRỰC-TIẾP ngoài gói thầu +// KHÔNG bao giờ hiện trên cây, danh-sách là đường DUY NHẤT tới nó. +// • `WorkflowHistoryPanel` trước ở Panel 3 (chỗ nay là danh-sách) ⇒ dời xuống NGAY DƯỚI detail +// ở Panel 2 chứ không bỏ. Delta này plan W2 KHÔNG nêu (plan chỉ nói "panel-2 giữ detail") — +// khai ở đây để người sau khỏi tưởng ai đó lỡ tay đánh rơi. +// +// Mobile (< lg): GIỮ NGUYÊN 100% hiện trạng — chỉ danh-sách hiện, click row → fullpage +// /contracts/:id. Cây là khối DESKTOP-ONLY y như TRƯỚC hoán-đổi (nó vốn nằm trong panel +// `hidden lg:block`), nên đợt này không đổi một pixel nào ở < lg. +import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' import { FileText, Plus, Search } from 'lucide-react' @@ -16,6 +27,7 @@ import { SlaTimer } from '@/components/SlaTimer' import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' +import { Select } from '@/components/ui/Select' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import type { Paged } from '@/types/master' @@ -41,10 +53,27 @@ export function MyContractsPage() { const phaseFilter = searchParams.get('phase') ? Number(searchParams.get('phase')) : null const showDeleted = searchParams.get('deleted') === '1' + // [S190 W2 · Q2 nhánh-đã-chọn] Select loại HĐ ở đầu panel CÂY. Mặc định = `?type=N` của URL + // (menu loại HĐ đang đứng). 🔴 Đổi Select CHỈ đổi bộ lọc CÂY — KHÔNG navigate, KHÔNG đụng + // danh-sách panel-3 (danh-sách vẫn bám `?type=` của menu, đúng danh-tính leaf W1). + // + // 🔴 SYNC BẰNG PHÉP DẪN XUẤT, KHÔNG BẰNG `useEffect`: override được ĐÓNG DẤU loại nó thuộc về + // (`forType`). URL đổi loại (bấm sang "02. HĐ Giao khoán") ⇒ dấu không khớp nữa ⇒ tự rơi về + // mặc định MỚI, không cần đồng bộ tay. Bản đầu viết `useEffect(() => setTreeType(typeFilter))` + // và ăn ĐÚNG 1 lỗi `react-hooks/set-state-in-effect` (baseline eslint 47 → 48) — dẫn xuất vừa + // hết lỗi vừa hết cả một nhịp render thừa. + const [treeTypeOverride, setTreeTypeOverride] = + useState<{ forType: number | null; value: number | null } | null>(null) + const treeType = treeTypeOverride !== null && treeTypeOverride.forType === typeFilter + ? treeTypeOverride.value + : typeFilter + const list = useQuery({ // `typeFilter` ĐÃ nằm trong key từ trước ⇒ đổi sang lọc server KHÔNG cần thêm trục mới; // cache cũ (payload chưa lọc) tự hết hiệu lực vì mỗi `type` là 1 ô key riêng sẵn rồi. - queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted], + // [S190 W2] +trục `search`: ô tìm nay đi SERVER-SIDE (xem `params.search`) ⇒ mỗi từ khoá + // là một payload khác nhau, thiếu trục này là ăn cache của từ khoá trước. + queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted, search], queryFn: async () => ( await api.get>(showDeleted ? '/contracts/deleted' : '/contracts', { @@ -58,6 +87,13 @@ export function MyContractsPage() { // BE lọc TRƯỚC phân trang (`ContractFeatures.cs:325` trước `Skip/Take:340`). // `?? undefined` ⇒ axios bỏ hẳn key, giữ nguyên hành vi "không chọn loại". type: typeFilter ?? undefined, + // [S190 W2] 🔴 ĐỔI HÀNH-VI CÓ CHỦ ĐÍCH: ô tìm chuyển client-side → SERVER-SIDE. + // Param `search` sẵn ở CẢ 2 endpoint (`ContractsController.cs:19` và `:36`). + // Vì sao đổi: lọc client chỉ soi trong 100 dòng đã tải ⇒ user nhiều HĐ gõ đúng mã + // vẫn ra rỗng (cùng bệnh `type` đã vá @W7 S187). + // Diện tìm RỘNG HƠN bản cũ (`ContractFeatures.cs:358-364`): mã · tên HĐ · tên NCC + // · +tên DỰ ÁN (vế client cũ không có) — rộng hơn nên không mất kết quả nào. + search: search.trim() || undefined, ...(!showDeleted && phaseFilter != null ? { phase: phaseFilter } : {}), }, }) @@ -70,30 +106,19 @@ export function MyContractsPage() { enabled: !!selectedId, }) - const rows = useMemo(() => { - // [W7 S187] Vế `items.filter(c => c.type === typeFilter)` ĐÃ GỠ — BE lọc rồi. Giữ lại - // = lọc 2 lần, và che mất lỗi nếu server trả sai loại (sai sẽ THẦM LẶNG). `search` vẫn - // client-side CÓ CHỦ ĐÍCH: ô tìm không đổi request (payload 100 dòng đã tải trọn). - let items = list.data?.items ?? [] - if (search.trim()) { - const q = search.toLowerCase() - items = items.filter(c => - (c.maHopDong ?? '').toLowerCase().includes(q) || - (c.tenHopDong ?? '').toLowerCase().includes(q) || - (c.supplierName ?? '').toLowerCase().includes(q), - ) - } - return items - // `typeFilter` gỡ khỏi deps CÙNG LÚC với vế lọc (không còn đọc trong thân hàm). - }, [list.data, search]) + // [W7 S187] Vế `items.filter(c => c.type === typeFilter)` ĐÃ GỠ — BE lọc rồi. + // [S190 W2] Vế lọc `search` client-side cũng GỠ NỐT theo lý-do y hệt: giữ lại = lọc 2 lần và + // che mất lỗi nếu server trả sai (sai sẽ THẦM LẶNG). Không còn phép biến đổi nào ⇒ bỏ luôn + // `useMemo` (memo hoá một phép gán là chi phí thừa). + const rows = list.data?.items ?? [] function selectContract(id: string) { // [S159-đợt5 FLAG-2 reviewer] Màn "Đã xóa" = CHỈ XEM DANH SÁCH (khuôn PE S155 // `disabled={deletedView}`): detail endpoint KHÔNG IgnoreQueryFilters ⇒ HĐ đã xóa // 404 ⇒ desktop = 2 panel trắng, mobile = navigate sang trang 404. Chặn tại gốc. if (showDeleted) return - // Desktop ≥ lg: cập nhật URL để render Panel 2/3 cạnh List. - // Mobile: Panel 2/3 hidden → navigate fullpage /contracts/:id (UX khả dụng). + // Desktop ≥ lg: cập nhật URL để render Panel 2 cạnh cây/danh-sách. + // Mobile: Panel 2 hidden → navigate fullpage /contracts/:id (UX khả dụng). if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) { const next = new URLSearchParams(searchParams) next.set('id', id) @@ -149,10 +174,74 @@ export function MyContractsPage() { )} - {/* 3-panel grid — flex-1 để fill phần còn lại của viewport */} -
- {/* Panel 1 — List */} -