Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
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>
438 lines
24 KiB
TypeScript
438 lines
24 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, 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 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
|
||
// `<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 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á.'
|
||
: (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 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>
|
||
{/* [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>
|
||
)
|
||
}
|