// [S162 — 2026-07-30] Panel cây trái DÙNG CHUNG cho các trang giai đoạn khác // trang Duyệt NCC (owner chốt qua AskUser: "áp cả các trang GĐ khác"). // Cấu trúc y hệt cây trang Duyệt NCC — 📅 Năm > 📁 Dự án > 🧱 Hạng mục > 4 folder // giai đoạn — nên người dùng thấy CÙNG một cây ở mọi trang; folder của TRANG // ĐANG ĐỨNG mở sẵn. // File MIRROR SHA256 identical giữa 2 app (fe-user ⟂ fe-admin) — sửa 1 bên PHẢI copy sang bên kia. // // 🔴 Cây trang Duyệt NCC (`pages/pe/PurchaseEvaluationsListPage.tsx:210-252`) KHÔNG // bị đụng vào: nó có bộ lọc `pendingMe` + `?deleted=1` riêng. Bộ dựng nhóm ở đây // là bản độc lập (duplicate CÓ CHỦ ĐÍCH) để trang đang chạy giữ nguyên hành vi. import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { ListTree, Search } from 'lucide-react' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import { usePipelineStages, type BuildStagesOptions } from '@/hooks/usePipelineStages' import { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders' import type { Paged } from '@/types/master' import type { PeListItem } from '@/types/purchaseEvaluation' const STORAGE_KEY = 'pipeline_tree_expanded_v1' const PE_INDEX_PAGE_SIZE = 200 type WorkItemNode = { workItemId: string | null; workItemName: string; items: PeListItem[] } type ProjectNode = { projectId: string | null projectName: string projectCode: string workItems: WorkItemNode[] totalCount: number } type YearNode = { year: number; projects: ProjectNode[]; totalCount: number } function buildTree(rows: PeListItem[]): YearNode[] { const yearMap = new Map() for (const p of rows) { const year = new Date(p.createdAt).getFullYear() let yg = yearMap.get(year) if (!yg) { yg = { year, projects: [], totalCount: 0 } yearMap.set(year, yg) } const projKey = p.projectId ?? '__no_project__' let pg = yg.projects.find(g => (g.projectId ?? '__no_project__') === projKey) if (!pg) { pg = { projectId: p.projectId ?? null, projectName: p.projectName?.trim() || '(Dự án đã xoá)', projectCode: p.projectCode || '', workItems: [], totalCount: 0, } yg.projects.push(pg) } const wiKey = p.workItemId ?? '__no_workitem__' let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey) if (!wg) { wg = { workItemId: p.workItemId ?? null, workItemName: p.workItemName?.trim() || '(Chưa gắn hạng mục)', items: [], } pg.workItems.push(wg) } wg.items.push(p) pg.totalCount++ yg.totalCount++ } const arr = Array.from(yearMap.values()) arr.sort((a, b) => b.year - a.year) for (const yg of arr) { yg.projects.sort((a, b) => (a.projectCode || a.projectName).localeCompare(b.projectCode || b.projectName, 'vi'), ) for (const pg of yg.projects) { // numeric:true — tên hạng mục mở đầu bằng STT không pad ("2 Mat…" < "10 Mat…"). pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi', { numeric: true })) for (const wg of pg.workItems) { wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) } } } return arr } export function PipelineTreePanel({ currentStage, className, bodyMaxHClass, stage2Options, hideHeader, hideSearch, }: { currentStage: PipelineStageNo className?: string // [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. // [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). // Mặc định giữ nguyên — 3 host giấy-cuộn cũ không đổi hành vi. bodyMaxHClass?: string // [S174 owner] Ẩn thanh tiêu-đề "Cây toàn trình · N gói". CHỈ dùng ở host đã có tiêu-đề // riêng ngay trên (KHKK GĐ2: header trang `KhkkListPage.tsx:124`) — 2 tiêu-đề chồng nhau // trong cùng một cột là thừa. 6 host còn lại KHÔNG truyền ⇒ giữ nguyên hình cũ. // 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi // ` { const d = ( await api.get>('/purchase-evaluations', { params: { page: 1, pageSize: PE_INDEX_PAGE_SIZE }, }) ).data return { items: d.items, total: d.total } }, enabled: canPe, staleTime: 60_000, retry: 1, }) // `null` = người dùng CHƯA từng chỉnh ⇒ dùng nhánh mở mặc định bên dưới. // 🔴 KHÔNG dùng mẹo "set rỗng = mặc định": đóng node cuối lại cho ra set rỗng // ⇒ mặc định bật lại ⇒ node KHÔNG đóng được (soi FD2 r1). const [expandedSet, setExpandedSet] = useState | null>(() => { try { const raw = localStorage.getItem(STORAGE_KEY) return raw ? new Set(JSON.parse(raw) as string[]) : null } catch { return null } }) const term = search.trim().toLowerCase() const rows = useMemo(() => { const all = peIndex.data?.items ?? [] if (!term) return all return all.filter(p => [p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau] .some(v => (v ?? '').toLowerCase().includes(term)), ) }, [peIndex.data, term]) const years = useMemo(() => buildTree(rows), [rows]) const packageCount = years.reduce((n, y) => n + y.projects.reduce((m, p) => m + p.workItems.length, 0), 0) // Đang tìm kiếm ⇒ chỉ hiện folder CÓ nội dung khớp (không đổ 4 folder rỗng). const hideEmpty = term.length > 0 // Mặc định mở HẾT MỘT NHÁNH (năm mới nhất > dự án đầu > hạng mục đầu) để vừa // vào trang đã THẤY NGAY folder của giai đoạn đang đứng — mở mỗi năm thì folder // nằm sâu 3 lớp, người dùng phải bấm 2 lần mới thấy (soi FD2 r1-B/C). const defaultOpen = useMemo(() => { const keys = new Set() const y = years[0] if (!y) return keys const yearKey = `y${y.year}` keys.add(yearKey) const p = y.projects[0] if (!p) return keys const projKey = `${yearKey}::p${p.projectId ?? '_none_'}` keys.add(projKey) const w = p.workItems[0] if (w) keys.add(`${projKey}::w${w.workItemId ?? '_none_'}`) return keys }, [years]) const effectiveOpen = expandedSet ?? defaultOpen // Đang lọc ⇒ mở hết để thấy kết quả khớp (cây thu gọn khi tìm = trông như hỏng). const isExpanded = (key: string) => (term ? true : effectiveOpen.has(key)) const toggleExpand = (key: string, open: boolean) => { if (term) return // đang lọc thì cây bị ép mở — đừng ghi đè lựa chọn cũ setExpandedSet(prev => { const next = new Set(prev ?? defaultOpen) if (open) next.add(key) else next.delete(key) try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) } catch { /* quota/private mode */ } return next }) } return ( ) }