Files
solution-erp/fe-user/src/components/pipeline/PipelineTreePanel.tsx
pqhuy1987 a0ccac3982
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m24s
[CLAUDE] Contract: UAT S174 dot-D + C3 — O6/O4/O7/O8 + 2 tong toan phieu (FE-only, 0-mig)
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=<id>` 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.
`<lg` roi ve duong cu (khong co panel 2 thi xem-tai-cho la bat kha).
Panel 3 doi chu khi dang xem PE — cau cu "se hien khi chon phieu" la SAI (phieu DA chon roi).

=== O4 — shopdrawing / can cu ho so cho dinh kem file (FE-only) ===
🔴 0 BE MOI — rail da du: controller `[FromForm] Guid? dossierItemId` (:181) + DTO
`KhkkAttachmentDto.dossierItemId` (:241) co tu Mig 69. FE truoc day don gian la KHONG BAO GIO
gui truong do len => 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 <button> cua card — long button trong button la HTML khong hop le.
Host: but chi => mo che-do SUA; than card => chi xem nhu cu. Guard readOnly Y HET
`KhkkListPage:147-150` (cung mot cau hoi "ai duoc sua" phai cho cung mot cau tra loi o moi man).
Da verify khong phai co-hoc: `KhkkDetailContent:161` `isDraft = draftPhase && !readOnly`.

=== O8 — cay 2 cap Du an > Hang muc ===
🔴 0 ENDPOINT MOI: `workItemName` DA co san trong `KhkkPeCandidateDto:423` => cap 2 la gom lai
tren du-lieu dang co. Truoc day 1 cap theo du an nen mot du an nhieu goi thau do ra danh sach
phang dai, khong thay ranh gioi hang muc.
🔴 2 muc `z` khac nhau (z-20 cha / z-10 con): cung z thi header hang muc truot DE len header
du an khi cuon, mat cap cha.

=== C3 — Field: value ve slate-900, label giu brand-600 ===
Truoc day MOI value deu to brand => mau thuong-hieu mat het suc nang: ca trang deu la mau nhan
thi khong con gi la nhan, va khong chua duoc mau cho thu THAT SU can noi (NCC trung thau,
canh bao vuot ngan sach). PE giu slate cho value dung vi ly do nay.

=== O3 — MOI LAND VE (2). VE (1) CHUA LAM — xem CON NO ===
🔴 KHONG cong gop 2 cot thanh mot so "hien hanh": de-xuat va duyet la 2 so co y nghia phap-ly
khac nhau (mot cai chua ai chot, mot cai da qua choke-point `ApplyApprovedValuesOnFinalize`);
tron lai la bia ra mot so khong ton tai trong ho-so nao.
Tong duyet chi cong phan DA co, va noi ro con bao nhieu dong chua chot.

=== 🔴 CON NO — O3 ve (1) "hang muc cho chon NHIEU" KHONG PHAI FE-ONLY ===
Spec ghi O3 = "N-ROW 0-migration". "0-migration" DUNG (unique 3 cot PlanId+SupplierId+
CatalogEntryId co tu Mig 71) NHUNG KHONG co nghia la 0 BE. Do that:
  · KHONG co endpoint TAO line. Chi co `PUT /{id}/lines/{lineId}` = gan catalogEntryId cho
    dong DA CO (Controller:121). Danh sach route day du: 0 route POST/DELETE line.
  · Line duoc auto-sinh 1-dong-moi-NCC-trung-thau luc TAO PHIEU (`Features.cs:508-527`),
    tien moi = tong bao gia DUOC CHON cua NCC do.
=> Muon 1 NCC co N hang muc phai TACH tien cua NCC do ra N phan — va KHONG CO quy tac nao
   trong he thong noi chia the nao. Do la cau hoi TIEN, chua ai tra loi.
🔸 Tin tot: BE cau HD da nhan N dong san (`CreateContractFromSigningPlanFeatures:101-115`
   nhan `LineIds` list, chi rao "moi HD 1 NCC"). Cai hep la FE dialog dung radio single-select
   (`KhkkDetailPage:310`) — do la UI, doi duoc.

=== CON NO KHAC ===
🔴 CHUA soi mat tren prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3) — moi ket luan la
DOC MA, khong phai da nhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:59:04 +07:00

364 lines
19 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 } 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<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)
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<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
}) {
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(() => {
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<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:112` NẰM NGOÀI `@layer` nên THẮNG utility 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 đè.
!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á.' : '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>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
</summary>
<PipelineStageFolders
className="ml-3 border-l border-slate-200"
currentStage={currentStage}
hideEmpty={hideEmpty}
stages={buildStages(wg.items, stage2Options ?? {})}
/>
</details>
)
})}
</div>
</details>
)
})}
</div>
</details>
)
})}
</div>
</div>
</div>
</aside>
)
}