All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m54s
- Cay 4-folder giai doan duoi tung goi thau (bfc7b79, cicd PASS 5/5 Run #432)
- Bookend-close: H1 PASS_WITH_FLAGS · H2 GATE-FAIL 4 · H24 20 FLAG · trio MIXED
· ring1 26D/2T · ring2 19D/1T · harness-audit 43D/4T · ctx-audit TRUOT 7 FLAG
- Lead va trong phien: F-01/F-02/F-04 + A1 archive + A2 distill + skill KHKK
+ comment mirror self-ref (SHA-pair 6/6 giu, build x2 EXIT 0)
- MIND-5 = refresh @closeout dau tien (nhip thu 5, theo de ctx-audit)
- STATUS 3 row canonical + dong CURRENT · HANDOFF 6 slot danh so (54)-(59)
- gotcha #85 · error-ledger E-015/E-016 + AS-19/AS-20 · Phase 12 roadmap
- 4 errata khai thang: tally view-stale-count thoi 1 · enum thieu 1 class
· tong L1 lech 54 B · A2 tren trigger duong-gia (R10)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
322 lines
15 KiB
TypeScript
322 lines
15 KiB
TypeScript
// [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 } 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 có 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 cũ 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>
|
|
)
|
|
}
|