[CLAUDE] FE-User+FE-Admin: cay 4 folder GD duoi tung goi thau (Duyet NCC / Ke hoach HD / Duyet HD / HD cung) x2 app + 5 fix review F-1/3/4/6/7
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-30 10:04:32 +07:00
parent 8eba1379b2
commit bfc7b79f72
17 changed files with 1839 additions and 12 deletions

View File

@ -0,0 +1,237 @@
// [S162 — 2026-07-30] Cây 4 folder GIAI ĐOẠN, đặt DƯỚI TỪNG GÓI THẦU.
// Owner 30-07 (screenshot khoanh đỏ dải 4 GĐ trỏ xuống cây trái trang Duyệt NCC):
// "Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng / Duyệt Hợp đồng / Hợp đồng cứng",
// AskUser chốt (1) folder nằm DƯỚI TỪNG GÓI THẦU · (2) áp CẢ các trang GĐ khác.
//
// 🔴 Nhãn folder VERBATIM lời owner — GĐ2 CỐ Ý khác nhãn dải strip
// (`components/pe/PePipelineStrip.tsx` = "Kế hoạch ký kết HĐ"): lời-mới-nhất-thắng.
// KHÔNG sửa strip, KHÔNG sửa nhãn menu.
//
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// Component này THUẦN TRÌNH BÀY: nhận `stages` đã dựng sẵn (xem
// `hooks/usePipelineStages.ts`) rồi vẽ. GĐ1 trên trang Duyệt NCC truyền `content`
// = đúng khối leaf phiếu CŨ ⇒ mọi behavior (chọn phiếu / chip GẤP / nút mở rộng)
// giữ nguyên, chỉ được RE-PARENT vào trong folder.
import { useState, type ReactNode } from 'react'
import { ChevronRight, ClipboardCheck, FileCheck, FileSignature, Lock, Stamp } from 'lucide-react'
import { cn } from '@/lib/cn'
export type PipelineStageNo = 1 | 2 | 3 | 4
export const PIPELINE_STAGE_LABELS: Record<PipelineStageNo, string> = {
1: 'Duyệt NCC',
2: 'Kế hoạch Hợp đồng',
3: 'Duyệt Hợp đồng',
4: 'Hợp đồng cứng',
}
const STAGE_ICON: Record<PipelineStageNo, typeof ClipboardCheck> = {
1: ClipboardCheck,
2: FileCheck,
3: FileSignature,
4: Stamp,
}
// Pattern 14 (Tailwind JIT): class dạng LITERAL đầy đủ, không nội suy mảnh.
// Palette `@theme` (index.css:29-51) chỉ có stop 50/100/500/600/700 cho teal /
// violet / amberx ⇒ head dùng -700 (riêng brand có tới -900).
const STAGE_STYLE: Record<PipelineStageNo, { chip: string; head: string; count: string; code: string }> = {
1: { chip: 'bg-brand-50 text-brand-600', head: 'text-brand-800', count: 'bg-brand-50 text-brand-700', code: 'text-brand-700' },
2: { chip: 'bg-violet-50 text-violet-600', head: 'text-violet-700', count: 'bg-violet-50 text-violet-700', code: 'text-violet-700' },
3: { chip: 'bg-amberx-50 text-amberx-600', head: 'text-amberx-700', count: 'bg-amberx-50 text-amberx-700', code: 'text-amberx-700' },
4: { chip: 'bg-teal-50 text-teal-600', head: 'text-teal-700', count: 'bg-teal-50 text-teal-700', code: 'text-teal-700' },
}
export type PipelineLeaf = {
id: string
/** Dòng 1 — mã (font-mono). */
primary: string
/** Dòng 2 — tên gói thầu / tên HĐ. */
secondary?: string | null
badgeLabel?: string | null
/** Class ĐẦY ĐỦ cho badge (Pattern 14). */
badgeClass?: string | null
onOpen?: () => void
}
export type PipelineStage = {
n: PipelineStageNo
leaves: PipelineLeaf[]
/** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */
locked?: boolean
/** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */
failed?: boolean
loading?: boolean
/** Nội dung tự vẽ (GĐ1 trang Duyệt NCC dùng để giữ nguyên leaf phiếu cũ). */
content?: ReactNode
/** Bắt buộc khi dùng `content` (component không đếm hộ được). */
count?: number
/** Index nguồn chạm trần cửa sổ 200 (`PagedResult.cs:20`) ⇒ đếm CÓ THỂ thiếu. */
truncated?: boolean
}
function stageCount(stage: PipelineStage): number {
return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length
}
function SoonChip() {
// Token lấy verbatim từ dải strip (PePipelineStrip.tsx:50-52) — uppercase CSS
// nên chữ "Sắp" hiển thị "SẮP" đúng như owner gõ.
return (
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
Sắp
</span>
)
}
function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) {
// Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được.
const [open, setOpen] = useState(stage.n === currentStage)
const label = PIPELINE_STAGE_LABELS[stage.n]
const style = STAGE_STYLE[stage.n]
const Icon = STAGE_ICON[stage.n]
const count = stageCount(stage)
const muted = !!stage.locked || (!stage.failed && count === 0)
// Khoá quyền: KHÔNG render <details> (không có gì để mở, và cũng không được
// phép gọi API để biết) — chỉ 1 dòng khoá + chip "Sắp".
// 🔴 Giữ ĐÚNG 3 khoang như dòng folder thường (khoang chevron ⇢ ổ khoá · icon-chip
// · nhãn) để 4 dòng thẳng hàng tuyệt đối; bỏ khoang là lệch 26px (soi FD2 r1-D).
if (stage.locked) {
return (
<div
className="flex items-center gap-1.5 px-3 py-1.5"
title="Bạn chưa có quyền xem giai đoạn này"
>
<Lock aria-hidden className="h-3 w-3 shrink-0 text-slate-400" />
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
<Icon aria-hidden className="h-3 w-3" />
</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
<SoonChip />
</div>
)
}
return (
<details
open={open}
onToggle={e => setOpen((e.currentTarget as HTMLDetailsElement).open)}
className="group/stage"
>
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60 [&::-webkit-details-marker]:hidden">
<ChevronRight
aria-hidden
className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/stage:rotate-90 motion-reduce:transition-none"
/>
<span
className={cn(
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded',
muted ? 'bg-slate-100 text-slate-400' : style.chip,
)}
>
<Icon aria-hidden className="h-3 w-3" />
</span>
{/* Folder rỗng VẪN bấm mở được ⇒ nhãn là chữ ĐANG HOẠT ĐỘNG: slate-400
(#94a3b8) chỉ 2.8:1 — dưới sàn AA 4.5:1 ⇒ dùng slate-500 (4.8:1).
Chip "Sắp"/icon giữ slate-400 theo token dải strip (trạng thái trơ). */}
<span className={cn('min-w-0 flex-1 truncate text-[12px] font-medium', muted ? 'text-slate-500' : style.head)}>
{label}
</span>
{stage.failed ? (
<span
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-400"
title="Không tải được dữ liệu giai đoạn này"
>
</span>
) : count > 0 ? (
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
) : (
<SoonChip />
)}
</summary>
<div className="ml-3 border-l border-slate-200">
{stage.failed ? (
<div className="px-3 py-2 text-[11px] text-slate-400">Không tải đưc dữ liệu giai đoạn này.</div>
) : stage.loading ? (
<div className="space-y-1.5 px-3 py-2">
<div className="h-3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
<div className="h-3 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
</div>
) : stage.content !== undefined ? (
stage.content
) : stage.leaves.length === 0 ? (
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa dữ liệu giai đoạn này.</div>
) : (
<ul className="divide-y divide-slate-100">
{stage.leaves.map(leaf => (
<li key={leaf.id}>
<button
type="button"
onClick={leaf.onOpen}
disabled={!leaf.onOpen}
className={cn(
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
)}
>
<div className="flex items-center gap-1.5">
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
{leaf.primary}
</span>
{leaf.badgeLabel && (
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
)}
>
{leaf.badgeLabel}
</span>
)}
</div>
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
</button>
</li>
))}
</ul>
)}
{stage.truncated && !stage.failed && !stage.loading && (
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
Chỉ mục tải 200 bản ghi mới nhất số đếm thể thiếu.
</div>
)}
</div>
</details>
)
}
export function PipelineStageFolders({
stages,
currentStage,
hideEmpty = false,
className,
}: {
stages: PipelineStage[]
currentStage: PipelineStageNo
/** Đang lọc (search / trạng thái) ⇒ chỉ hiện folder CÓ nội dung khớp. */
hideEmpty?: boolean
className?: string
}) {
// [F-6 S162] Đang lọc vẫn PHẢI thấy folder lỗi/đang tải — ẩn dấu "—" đi là
// nói dối trạng thái; chỉ ẩn folder RỖNG THẬT (ok/locked, count 0).
const visible = hideEmpty ? stages.filter(s => stageCount(s) > 0 || s.failed || s.loading) : stages
if (visible.length === 0) return null
return (
<div className={className}>
{visible.map(s => (
<StageFolder key={s.n} stage={s} currentStage={currentStage} />
))}
</div>
)
}

View File

@ -0,0 +1,321 @@
// [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 với fe-admin counterpart.
//
// 🔴 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 } 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,
}: {
currentStage: PipelineStageNo
className?: 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(() => {
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('card-accent flex flex-col self-start overflow-hidden 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"
>
<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>
<div className={cn('min-h-0 flex-col', mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
<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="max-h-[28rem] overflow-y-auto 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)}
/>
</details>
)
})}
</div>
</details>
)
})}
</div>
</details>
)
})}
</div>
</div>
</div>
</aside>
)
}