Files
solution-erp/fe-admin/src/components/pipeline/PipelineTreePanel.tsx
pqhuy1987 57e672f71f
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
[CLAUDE] Docs: S179 closeout — va doi STYLE 2 vong (C7 bat 2 MAJOR trong chinh ban va) + H24 9 FLAG + drift-audit thang
SUA-1..6b tron 6/6, acceptance C1-C6b 12/12. Vong-2 sua chinh vong-1 sau reviewer C7 (PASS-WITH-8, 3 MAJOR):
- Doi ba-cham -> hai-cham la REGRESSION co dieu-kien, KHONG phai cosmetic: chi tuong-duong khi origin/main
  con ancestor; cay phan-ky thi hai-cham liet ca file CHI CO tren origin/main (= DA PRODUCTION) vao "phan MOI".
  Thu pham that = git diff mu untracked. Sandbox 2 chieu CONFIRMED.
- git status --porcelain | cut -c4- de path RAC (rename in MOT dong "R old -> new"; path co dau cach bi quote).
  Sandbox: 2/2 MISS; thuoc moi 2/2 OK.
- Thuoc CHOT 4/4 site: { git diff --name-only origin/main...HEAD; git diff --name-only HEAD;
  git ls-files -o --exclude-standard; } | sort -u

Bai: chan-doan sai VAN pass acceptance — acceptance do HINH-DANG chuoi, khong do HANH-VI.

Lop mo-neo TU-VO-HIEU: con-tro so-dong tro vao CHINH tep chua no tu thoi moi lan sua dau tep
(112->118->121 trong 1 phien) => neo TEN, khong neo SO DONG. Ap cho index.css, gotchas.md, PipelineStageFolders.
Con-tro verbatim _mind doi ve commit DA PUSH (ee21056/d081681) — ban truoc tro 727a512 nam trong dai squash
=> tu de con-tro mo-coi.

H24 light-audit 9 FLAG, flush 8 o canonical: Mig 71->72 - Gotchas 86->87 - Tests 644->645 (chay that
dotnet test: 45 Domain + 600 Infra, 0 fail) - user-mem 59->60 - header S168->S179 - roster 23->26 (go han so
theo B1) - HANDOFF dong 4 slot (68)-(71) + E4.

"So noi doi theo huong BI QUAN" x3 ca/1 phien (4 slot - E4 - run.md S173) => leo thang lan 3 phai DO LAI TIEN-DE.

Kem: 2 NO vong-3 - 2 synthesis ORPHAN (run-chua-gom 2->0) - di-tru cua-so 6 vao _context - keo+ap 2 thu
loi-do-luong (BAC Lop 8 CO SO: mtime cum SE 0,7% vs hub 69%) - drift-audit thang PASS_WITH_FLAGS-8 (va 2, con 6)
- _mind 99,67%->84,2% - don 9 dir rong - archive-gate strike 1/2.

#53: 4/4 vai garble return dau, vot tron 4/4 bang resume-in-session. Mat 0 byte.

0 file code nghiep-vu. Build 2/2 app PASS. Test 645/645.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:25:55 +07:00

438 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// [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, type ReactNode } 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 { PurchaseEvaluationPhase, 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 }
// [S176 owner] `weightOf` = mỗi PE đóng góp BAO NHIÊU vào badge đếm. Mặc định 1 (đếm
// gói thầu) ⇒ 6 host cũ không đổi một số nào. Chế-độ lá (`leafMode`) truyền số PHIẾU
// khớp bộ lọc, vì ở 3 menu Đang duyệt / Đã duyệt / Đã xóa thứ người dùng đang đếm là
// PHIẾU chứ không phải gói — và 1 gói có thể mang nhiều phiếu.
// 🔴 Cùng luật vòng-3: đếm phải khớp ĐÚNG tập đang hiện. Giữ `+1` ở chế-độ lá thì badge
// nói dối theo chiều ngược lại (gói có 2 phiếu vẫn kêu 1).
function buildTree(rows: PeListItem[], weightOf: (p: PeListItem) => number = () => 1): YearNode[] {
const yearMap = new Map<number, YearNode>()
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)
const w = weightOf(p)
pg.totalCount += w
yg.totalCount += w
}
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,
approvedPeOnly,
stagesOnly,
leafMode,
}: {
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<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe'>
// [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 100vh16rem 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
// `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây.
hideHeader?: boolean
// [S175 · B3] Ẩn ô lọc RIÊNG của cây. CHỈ dùng ở host đã có ô tìm của chính nó ngay trên
// (KHKK GĐ2) — ở đó 2 ô tìm xếp chồng nhau mà nghĩa lại khác: ô ngoài đi SERVER-SIDE và
// gõ vào là chuyển hẳn sang danh-sách phẳng (`flatMode`), nên ô trong cây KHÔNG bao giờ
// dùng tới — người dùng gõ phát nào là cây biến mất phát đó.
// 6 host còn lại KHÔNG truyền ⇒ giữ nguyên ô lọc cây như cũ.
hideSearch?: boolean
// [S175 — anh: "chỗ này chỉ cần NCC đã duyệt là đc rồi nhé, còn lại bỏ hết đi"]
// Chỉ giữ phiếu Duyệt NCC ở phase DaDuyet. Màn Thao tác dùng cây này để CHỌN PE mà lập
// kế hoạch — phiếu chưa duyệt xong thì lập không được, hiện ra chỉ tổ mời bấm nhầm.
// 🔴 OPT-IN: 5 host còn lại không truyền ⇒ cây giữ NGUYÊN mọi phase như cũ.
approvedPeOnly?: boolean
// [S175] Chỉ vẽ các folder giai-đoạn trong danh sách này (vd `[1]` = chỉ Duyệt NCC).
// Không truyền ⇒ vẽ đủ 4 giai-đoạn như cũ.
stagesOnly?: PipelineStageNo[]
// [S176 owner — "ở các menu đang duyệt/ đã duyệt/ xóa → cho hiển thị năm → dự án →
// hạng mục → phiếu KHKK nhé"] CHẾ-ĐỘ LÁ: cây dừng ở tầng Hạng mục rồi vẽ THẲNG phiếu,
// bỏ tầng folder giai-đoạn VÀ tầng 8 ngăn nhóm. Đúng 4 tầng anh gõ.
// 🔴 Panel này KHÔNG biết gì về kiểu KHKK — host cấp cả phép ĐẾM lẫn phép VẼ, nên cây
// dùng lại được cho giai-đoạn khác sau này mà không kéo theo import chéo.
// 🔴 OPT-IN: 6 host còn lại không truyền ⇒ đi nhánh `PipelineStageFolders` y như cũ.
leafMode?: {
/** Số phiếu của PE này SAU bộ lọc. Trả 0 ⇒ nhánh bị TỈA khỏi cây (không hiện folder rỗng). */
countForPe: (pe: PeListItem) => number
/** Thân tầng Hạng mục = phiếu của đúng các PE trong hạng mục đó. */
render: (peItems: PeListItem[]) => ReactNode
/**
* Câu hiển thị khi bộ lọc tỉa sạch cây. Mặc định "Chưa có phiếu nào để dựng cây" nói
* về DỰNG CÂY — ở chế-độ lá nó đánh lừa: người dùng lọc "Đã duyệt" mà rỗng sẽ tưởng
* hệ-thống hỏng chứ không hiểu là không phiếu nào khớp bộ lọc.
*/
emptyText?: string
}
}) {
const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('')
const [mobileOpen, setMobileOpen] = useState(false)
// Cây cần TOÀN BỘ phiếu (không lọc trạng thái) để nhìn được toàn trình.
// Key riêng — KHÔNG dùng `pe-list` của trang Duyệt NCC (khác tham số, dùng
// chung sẽ đá nhau).
const peIndex = useQuery({
queryKey: ['pipeline-pe-index'],
// [F-4 S162] giữ cả `total` — chạm trần cửa sổ thì khai ngay trên cây.
queryFn: async () => {
const d = (
await api.get<Paged<PeListItem>>('/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<Set<string> | 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(() => {
// [S175] Lọc phase TRƯỚC khi dựng cây ⇒ số đếm ở mọi tầng (Năm/Dự án/Hạng mục) tự khớp
// tập đã lọc. Lọc SAU khi dựng thì badge đếm sẽ nói dối.
const base = peIndex.data?.items ?? []
const all = approvedPeOnly
? base.filter(p => p.phase === PurchaseEvaluationPhase.DaDuyet)
: base
const searched = !term
? all
: all.filter(p =>
[p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau]
.some(v => (v ?? '').toLowerCase().includes(term)),
)
// [S176] TỈA nhánh rỗng ở chế-độ lá: gói thầu không còn phiếu nào khớp bộ lọc thì
// KHÔNG dựng nhánh cho nó. Cùng lý-do "lọc TRƯỚC khi dựng" ở trên — để lại nhánh rỗng
// thì người dùng mở Năm→Dự án→Hạng mục ba lần chỉ để thấy chỗ trống.
return leafMode ? searched.filter(p => leafMode.countForPe(p) > 0) : searched
}, [peIndex.data, term, approvedPeOnly, leafMode])
// [S176] Chế-độ lá đếm PHIẾU (`countForPe`); mặc định đếm GÓI (mỗi PE = 1) như cũ.
const years = useMemo(
() => buildTree(rows, leafMode ? p => leafMode.countForPe(p) : undefined),
[rows, leafMode],
)
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<string>()
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 (
<aside
className={cn(
'flex flex-col self-start overflow-hidden',
// [S174 owner] "style cho nó giống nhau đồng bộ nhé, style bên KHKK có cái viền bên
// ngoài" — `hideHeader` = đang NHÚNG trong panel khác ⇒ bỏ vỏ card + sticky để liền
// mạch với host, y như cây bên Duyệt NCC.
// 🔴 KHÔNG vá bằng cách truyền `border-0 shadow-none`: `.card-accent` khai ở
// `index.css:118` NẰM NGOÀI `@layer` nên THẮNG utility THƯỜNG của Tailwind v4 (gotcha #66)
// ⇒ cách đó bị vô hiệu IM LẶNG (đúng thứ vừa xảy ra). Phải GỠ CLASS, không đè.
// 🔸 Chính-xác hoá @S179: chỉ utility THƯỜNG mới thua; `!important` (`text-white!`) VẪN
// thắng — chính là fix #66 đề-xuất. Ở đây gỡ class vì THIẾT-KẾ, không phải vì bất-khả-thi.
!hideHeader && 'card-accent lg:sticky lg:top-4',
className,
)}
style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
aria-label="Cây toàn trình theo gói thầu"
>
{!hideHeader && (
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
<span
className="icon-chip h-7! w-7!"
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
aria-hidden
>
<ListTree className="h-3.5 w-3.5" />
</span>
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{packageCount} gói
</span>
<button
type="button"
onClick={() => setMobileOpen(o => !o)}
aria-expanded={mobileOpen}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
>
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
</button>
</div>
)}
{/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */}
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
{!hideSearch && (
<div className="relative border-b border-slate-200 p-2">
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Lọc dự án / hạng mục / mã phiếu…"
aria-label="Lọc cây toàn trình"
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
/>
</div>
)}
<div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
{!canPe && (
<p className="px-3 py-4 text-[12px] text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị đưc.
</p>
)}
{canPe && peIndex.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
))}
</div>
)}
{canPe && peIndex.isError && (
<div className="space-y-2 px-3 py-4">
<p className="text-[12px] text-slate-500">Không tải đưc cây toàn trình.</p>
<button
type="button"
onClick={() => peIndex.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70"
>
Thử lại
</button>
</div>
)}
{canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && (
<p className="px-3 py-4 text-[12px] text-slate-500">
{term
? 'Không có gói thầu nào khớp từ khoá.'
: (leafMode?.emptyText ?? 'Chưa có phiếu nào để dựng cây.')}
</p>
)}
{canPe && peIndex.data && peIndex.data.total > peIndex.data.items.length && (
<p className="border-b border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
Cây tải 200 phiếu mới nhất gói hơn không hiển thị.
</p>
)}
<div className="divide-y divide-slate-100">
{years.map(yg => {
const yearKey = `y${yg.year}`
return (
<details
key={yearKey}
open={isExpanded(yearKey)}
onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
className="group/year"
>
<summary className="flex cursor-pointer items-center gap-1.5 bg-slate-50 px-3 py-2 hover:bg-slate-100 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-500 transition-transform group-open/year:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-base">📅</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-900">Năm {yg.year}</span>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{yg.totalCount}</span>
</summary>
<div className="ml-3 border-l border-slate-200">
{yg.projects.map(pg => {
const projKey = `${yearKey}::p${pg.projectId ?? '_none_'}`
return (
<details
key={projKey}
open={isExpanded(projKey)}
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
className="group/proj"
>
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/proj:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">📁</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-700" title={pg.projectName}>{pg.projectCode || pg.projectName}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{pg.totalCount}</span>
</summary>
<div className="ml-3 border-l border-slate-200">
{pg.workItems.map(wg => {
const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}`
return (
<details
key={wiKey}
open={isExpanded(wiKey)}
onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)}
className="group/wi"
>
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">🧱</span>
<span className={cn('min-w-0 flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
{/* [S176] Chế-độ lá đếm PHIẾU, mặc định đếm GÓI — khớp đúng
thứ đang hiện ngay dưới nó. */}
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">
{leafMode
? wg.items.reduce((n, p) => n + leafMode.countForPe(p), 0)
: wg.items.length}
</span>
</summary>
{leafMode ? (
// [S176 owner] 4 tầng: Năm Dự án Hạng mục PHIẾU.
// Không đi qua `PipelineStageFolders` ⇒ không có tầng GĐ,
// không có 8 ngăn nhóm.
<div className="ml-3 border-l border-slate-200">
{leafMode.render(wg.items)}
</div>
) : (
<PipelineStageFolders
className="ml-3 border-l border-slate-200"
currentStage={currentStage}
hideEmpty={hideEmpty}
stages={(() => {
const built = buildStages(wg.items, stage2Options ?? {})
// [S175] `stagesOnly` = lọc HIỂN THỊ, KHÔNG đụng bộ dựng —
// 5 host kia không truyền thì `built` đi thẳng như cũ.
return stagesOnly ? built.filter(st => stagesOnly.includes(st.n)) : built
})()}
/>
)}
</details>
)
})}
</div>
</details>
)
})}
</div>
</details>
)
})}
</div>
</div>
</div>
</aside>
)
}