[CLAUDE] PurchaseEvaluation: tree Panel 1 chot 4 tang "Nam > Du an > Hang muc cong viec > Phieu" (anh chot follow-up, SHA256 mirror x2 app)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m51s

- Doi tu "Du an (Nam)" gop 1 node -> Nam la tang ngoai cung (DESC, bg-slate-50),
  trong Nam chua Du an (A-Z vi), trong Du an chua Hang muc cong viec, roi phieu.
- yearGroups useMemo thay projectGroups + expand-state localStorage key v3.
- Build x2 PASS, SHA256 mirror identical 95D524EE.
This commit is contained in:
pqhuy1987
2026-06-11 16:36:59 +07:00
parent 56882acc4f
commit 0eafcd36e7
2 changed files with 250 additions and 200 deletions

View File

@ -115,44 +115,45 @@ export function PurchaseEvaluationsListPage() {
// Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp → // Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp →
// FE filter để UX đúng kỳ vọng. Phân quyền strict V2 BE pending Session 18+. // FE filter để UX đúng kỳ vọng. Phân quyền strict V2 BE pending Session 18+.
// S59 — anh Kiệt FDC (Zalo 2026-06-11): "Tên dự án ( 2026 ) -> Hạng mục công việc -> Phiếu cần duyệt" // S59 — anh Kiệt FDC (Zalo 2026-06-11) + anh chốt follow-up: tree 4 tầng
// (mirror cấu trúc folder Outlook FDC, thứ tự label "Dự án - Năm"). Thay tree AG5 cũ (Project > Năm > NCC): // "Năm (tạo phiếu) > Dự án > Hạng mục công việc > Phiếu cần duyệt".
// mỗi cặp (Dự án, Năm-tạo-phiếu) = 1 folder label "Tên dự án (Năm)", level 2 = Hạng mục công việc // Hạng mục từ WorkItemId Mig 49, phiếu cũ chưa gắn → "(Chưa gắn hạng mục)".
// (WorkItemId Mig 49, phiếu cũ chưa gắn → "(Chưa gắn hạng mục)"). NCC bỏ khỏi tree — vẫn hiện ở card/detail. // NCC bỏ khỏi tree — vẫn hiện ở card/detail. Filter pendingMe TRƯỚC group.
// Filter pendingMe TRƯỚC group. Sort: Project A-Z (vi) + Năm DESC + Hạng mục A-Z (vi) + PE createdAt DESC. // Sort: Năm DESC + Dự án A-Z (vi) + Hạng mục A-Z (vi) + PE createdAt DESC.
type WorkItemGroup = { type WorkItemGroup = {
workItemId: string | null workItemId: string | null
workItemName: string workItemName: string
items: PeListItem[] items: PeListItem[]
} }
type ProjectYearGroup = { type ProjectGroup = {
projectId: string | null projectId: string | null
projectName: string projectName: string
year: number
label: string
workItems: WorkItemGroup[] workItems: WorkItemGroup[]
totalCount: number totalCount: number
} }
const projectGroups = useMemo<ProjectYearGroup[]>(() => { type YearGroup = {
year: number
projects: ProjectGroup[]
totalCount: number
}
const yearGroups = useMemo<YearGroup[]>(() => {
const filtered = pendingMe const filtered = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) ? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
: allRows : allRows
const groupMap = new Map<string, ProjectYearGroup>() const yearMap = new Map<number, YearGroup>()
for (const p of filtered) { for (const p of filtered) {
const year = new Date(p.createdAt).getFullYear() const year = new Date(p.createdAt).getFullYear()
const projName = p.projectName?.trim() || '(Dự án đã xoá)' if (!yearMap.has(year)) {
const groupKey = `${p.projectId ?? '__no_project__'}::y${year}` yearMap.set(year, { year, projects: [], totalCount: 0 })
if (!groupMap.has(groupKey)) { }
groupMap.set(groupKey, { const yg = yearMap.get(year)!
projectId: p.projectId ?? null, const projKey = p.projectId ?? '__no_project__'
projectName: projName, const projName = p.projectName?.trim() || '(Dự án đã xoá)'
year, let pg = yg.projects.find(g => (g.projectId ?? '__no_project__') === projKey)
label: `${projName} (${year})`, if (!pg) {
workItems: [], pg = { projectId: p.projectId ?? null, projectName: projName, workItems: [], totalCount: 0 }
totalCount: 0, yg.projects.push(pg)
})
} }
const pg = groupMap.get(groupKey)!
const wiKey = p.workItemId ?? '__no_workitem__' const wiKey = p.workItemId ?? '__no_workitem__'
const wiName = p.workItemName?.trim() || '(Chưa gắn hạng mục)' const wiName = p.workItemName?.trim() || '(Chưa gắn hạng mục)'
let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey) let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey)
@ -162,24 +163,28 @@ export function PurchaseEvaluationsListPage() {
} }
wg.items.push(p) wg.items.push(p)
pg.totalCount++ pg.totalCount++
yg.totalCount++
} }
const arr = Array.from(groupMap.values()) const arr = Array.from(yearMap.values())
arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi') || b.year - a.year) arr.sort((a, b) => b.year - a.year)
for (const pg of arr) { for (const yg of arr) {
pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi')) yg.projects.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi'))
for (const wg of pg.workItems) { for (const pg of yg.projects) {
wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi'))
for (const wg of pg.workItems) {
wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
} }
} }
return arr return arr
}, [allRows, pendingMe]) }, [allRows, pendingMe])
// Total row count cho header badge (pendingMe đếm filtered, Danh sách đếm BE total). // Total row count cho header badge (pendingMe đếm filtered, Danh sách đếm BE total).
const totalRowCount = projectGroups.reduce((sum, pg) => sum + pg.totalCount, 0) const totalRowCount = yearGroups.reduce((sum, yg) => sum + yg.totalCount, 0)
// Plan AG2 — Expand state localStorage Set<string>. Default empty Set (all collapse). // Plan AG2 — Expand state localStorage Set<string>. Default empty Set (all collapse).
// S59 key v2: node scheme đổi (Dự án+Năm gộp 1 node + Hạng mục thay NCC) → key vô nghĩa. // S59 key v3: node scheme chốt "Năm > Dự án > Hạng mục" → key v1/v2 vô nghĩa.
const STORAGE_KEY = 'pe_list_expanded_projects_v2' const STORAGE_KEY = 'pe_list_expanded_projects_v3'
const [expandedSet, setExpandedSet] = useState<Set<string>>(() => { const [expandedSet, setExpandedSet] = useState<Set<string>>(() => {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
@ -276,92 +281,112 @@ export function PurchaseEvaluationsListPage() {
))} ))}
</div> </div>
)} )}
{!list.isLoading && projectGroups.length === 0 && ( {!list.isLoading && yearGroups.length === 0 && (
<div className="p-6"> <div className="p-6">
<EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." /> <EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
</div> </div>
)} )}
{/* S59 — Tree view theo anh Kiệt FDC: 📁 "Tên dự án (Năm)" (bg-slate-50) > 🧱 Hạng mục {/* S59 — Tree view chốt: 📅 Năm (bg-slate-50) > 📁 Dự án > 🧱 Hạng mục công việc > PE card.
công việc > PE card. 2 layer <details>, named groups group/proj + group/wi cho chevron. */} 3 layer <details>, named groups group/year + group/proj + group/wi cho chevron rotation. */}
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{projectGroups.map(pg => { {yearGroups.map(yg => {
const projKey = `${pg.projectId ?? '__no_project__'}::y${pg.year}` const yearKey = `y${yg.year}`
return ( return (
<details <details
key={projKey} key={yearKey}
open={isExpanded(projKey)} open={isExpanded(yearKey)}
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)} onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
className="group/proj" 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"> <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/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> <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="text-base">📅</span>
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.label}</span> <span className="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">{pg.totalCount}</span> <span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{yg.totalCount}</span>
</summary> </summary>
<div className="ml-3 border-l border-slate-200"> <div className="ml-3 border-l border-slate-200">
{pg.workItems.map(wg => { {yg.projects.map(pg => {
const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}` const projKey = `${yearKey}::p${pg.projectId ?? '_none_'}`
return ( return (
<details <details
key={wiKey} key={projKey}
open={isExpanded(wiKey)} open={isExpanded(projKey)}
onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)} onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
className="group/wi" 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"> <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> <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="text-sm">📁</span>
<span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span> <span className="flex-1 truncate text-[12px] font-medium text-slate-700">{pg.projectName}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span> <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{pg.totalCount}</span>
</summary> </summary>
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200"> <div className="ml-3 border-l border-slate-200">
{wg.items.map(p => ( {pg.workItems.map(wg => {
<li key={p.id}> const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}`
<button return (
onClick={() => selectRow(p.id)} <details
className={cn( key={wiKey}
'block w-full px-3 py-2 text-left transition hover:bg-slate-50', open={isExpanded(wiKey)}
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)}
)} className="group/wi"
> >
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */} <summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
<div className="flex items-start justify-between gap-2"> <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>
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div> <span className="text-sm">🧱</span>
<span <span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
className={cn( <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', </summary>
PeDisplayStatusColor[getPeDisplayStatus(p.phase)], <ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
)} {wg.items.map(p => (
> <li key={p.id}>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} <button
</span> onClick={() => selectRow(p.id)}
</div> className={cn(
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> 'block w-full px-3 py-2 text-left transition hover:bg-slate-50',
<span className="font-mono">{p.maPhieu ?? '—'}</span> selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
<span className="text-slate-300">·</span> )}
{/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt). */} >
<span title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}> {/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
{new Date(p.createdAt).toLocaleString('vi-VN', { <div className="flex items-start justify-between gap-2">
day: '2-digit', month: '2-digit', year: 'numeric', <div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
hour: '2-digit', minute: '2-digit', <span
})} className={cn(
</span> 'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
</div> PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
{(p.drafterName || p.departmentName || p.contractId) && ( )}
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px]"> >
<span className="min-w-0 flex-1 truncate text-slate-500"> {PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
{p.drafterName && <>👤 {p.drafterName}</>} </span>
{p.drafterName && p.departmentName && <span className="text-slate-300"> · </span>} </div>
{p.departmentName && <span className="text-slate-400">{p.departmentName}</span>} <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
</span> <span className="font-mono">{p.maPhieu ?? '—'}</span>
{p.contractId && <span className="shrink-0 text-[10px] font-medium text-brand-600"> </span>} <span className="text-slate-300">·</span>
</div> {/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt). */}
)} <span title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
</button> {new Date(p.createdAt).toLocaleString('vi-VN', {
</li> day: '2-digit', month: '2-digit', year: 'numeric',
))} hour: '2-digit', minute: '2-digit',
</ul> })}
</span>
</div>
{(p.drafterName || p.departmentName || p.contractId) && (
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 flex-1 truncate text-slate-500">
{p.drafterName && <>👤 {p.drafterName}</>}
{p.drafterName && p.departmentName && <span className="text-slate-300"> · </span>}
{p.departmentName && <span className="text-slate-400">{p.departmentName}</span>}
</span>
{p.contractId && <span className="shrink-0 text-[10px] font-medium text-brand-600"> </span>}
</div>
)}
</button>
</li>
))}
</ul>
</details>
)
})}
</div>
</details> </details>
) )
})} })}

View File

@ -115,44 +115,45 @@ export function PurchaseEvaluationsListPage() {
// Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp → // Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp →
// FE filter để UX đúng kỳ vọng. Phân quyền strict V2 BE pending Session 18+. // FE filter để UX đúng kỳ vọng. Phân quyền strict V2 BE pending Session 18+.
// S59 — anh Kiệt FDC (Zalo 2026-06-11): "Tên dự án ( 2026 ) -> Hạng mục công việc -> Phiếu cần duyệt" // S59 — anh Kiệt FDC (Zalo 2026-06-11) + anh chốt follow-up: tree 4 tầng
// (mirror cấu trúc folder Outlook FDC, thứ tự label "Dự án - Năm"). Thay tree AG5 cũ (Project > Năm > NCC): // "Năm (tạo phiếu) > Dự án > Hạng mục công việc > Phiếu cần duyệt".
// mỗi cặp (Dự án, Năm-tạo-phiếu) = 1 folder label "Tên dự án (Năm)", level 2 = Hạng mục công việc // Hạng mục từ WorkItemId Mig 49, phiếu cũ chưa gắn → "(Chưa gắn hạng mục)".
// (WorkItemId Mig 49, phiếu cũ chưa gắn → "(Chưa gắn hạng mục)"). NCC bỏ khỏi tree — vẫn hiện ở card/detail. // NCC bỏ khỏi tree — vẫn hiện ở card/detail. Filter pendingMe TRƯỚC group.
// Filter pendingMe TRƯỚC group. Sort: Project A-Z (vi) + Năm DESC + Hạng mục A-Z (vi) + PE createdAt DESC. // Sort: Năm DESC + Dự án A-Z (vi) + Hạng mục A-Z (vi) + PE createdAt DESC.
type WorkItemGroup = { type WorkItemGroup = {
workItemId: string | null workItemId: string | null
workItemName: string workItemName: string
items: PeListItem[] items: PeListItem[]
} }
type ProjectYearGroup = { type ProjectGroup = {
projectId: string | null projectId: string | null
projectName: string projectName: string
year: number
label: string
workItems: WorkItemGroup[] workItems: WorkItemGroup[]
totalCount: number totalCount: number
} }
const projectGroups = useMemo<ProjectYearGroup[]>(() => { type YearGroup = {
year: number
projects: ProjectGroup[]
totalCount: number
}
const yearGroups = useMemo<YearGroup[]>(() => {
const filtered = pendingMe const filtered = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) ? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
: allRows : allRows
const groupMap = new Map<string, ProjectYearGroup>() const yearMap = new Map<number, YearGroup>()
for (const p of filtered) { for (const p of filtered) {
const year = new Date(p.createdAt).getFullYear() const year = new Date(p.createdAt).getFullYear()
const projName = p.projectName?.trim() || '(Dự án đã xoá)' if (!yearMap.has(year)) {
const groupKey = `${p.projectId ?? '__no_project__'}::y${year}` yearMap.set(year, { year, projects: [], totalCount: 0 })
if (!groupMap.has(groupKey)) { }
groupMap.set(groupKey, { const yg = yearMap.get(year)!
projectId: p.projectId ?? null, const projKey = p.projectId ?? '__no_project__'
projectName: projName, const projName = p.projectName?.trim() || '(Dự án đã xoá)'
year, let pg = yg.projects.find(g => (g.projectId ?? '__no_project__') === projKey)
label: `${projName} (${year})`, if (!pg) {
workItems: [], pg = { projectId: p.projectId ?? null, projectName: projName, workItems: [], totalCount: 0 }
totalCount: 0, yg.projects.push(pg)
})
} }
const pg = groupMap.get(groupKey)!
const wiKey = p.workItemId ?? '__no_workitem__' const wiKey = p.workItemId ?? '__no_workitem__'
const wiName = p.workItemName?.trim() || '(Chưa gắn hạng mục)' const wiName = p.workItemName?.trim() || '(Chưa gắn hạng mục)'
let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey) let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey)
@ -162,24 +163,28 @@ export function PurchaseEvaluationsListPage() {
} }
wg.items.push(p) wg.items.push(p)
pg.totalCount++ pg.totalCount++
yg.totalCount++
} }
const arr = Array.from(groupMap.values()) const arr = Array.from(yearMap.values())
arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi') || b.year - a.year) arr.sort((a, b) => b.year - a.year)
for (const pg of arr) { for (const yg of arr) {
pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi')) yg.projects.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi'))
for (const wg of pg.workItems) { for (const pg of yg.projects) {
wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi'))
for (const wg of pg.workItems) {
wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
} }
} }
return arr return arr
}, [allRows, pendingMe]) }, [allRows, pendingMe])
// Total row count cho header badge (pendingMe đếm filtered, Danh sách đếm BE total). // Total row count cho header badge (pendingMe đếm filtered, Danh sách đếm BE total).
const totalRowCount = projectGroups.reduce((sum, pg) => sum + pg.totalCount, 0) const totalRowCount = yearGroups.reduce((sum, yg) => sum + yg.totalCount, 0)
// Plan AG2 — Expand state localStorage Set<string>. Default empty Set (all collapse). // Plan AG2 — Expand state localStorage Set<string>. Default empty Set (all collapse).
// S59 key v2: node scheme đổi (Dự án+Năm gộp 1 node + Hạng mục thay NCC) → key vô nghĩa. // S59 key v3: node scheme chốt "Năm > Dự án > Hạng mục" → key v1/v2 vô nghĩa.
const STORAGE_KEY = 'pe_list_expanded_projects_v2' const STORAGE_KEY = 'pe_list_expanded_projects_v3'
const [expandedSet, setExpandedSet] = useState<Set<string>>(() => { const [expandedSet, setExpandedSet] = useState<Set<string>>(() => {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
@ -276,92 +281,112 @@ export function PurchaseEvaluationsListPage() {
))} ))}
</div> </div>
)} )}
{!list.isLoading && projectGroups.length === 0 && ( {!list.isLoading && yearGroups.length === 0 && (
<div className="p-6"> <div className="p-6">
<EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." /> <EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
</div> </div>
)} )}
{/* S59 — Tree view theo anh Kiệt FDC: 📁 "Tên dự án (Năm)" (bg-slate-50) > 🧱 Hạng mục {/* S59 — Tree view chốt: 📅 Năm (bg-slate-50) > 📁 Dự án > 🧱 Hạng mục công việc > PE card.
công việc > PE card. 2 layer <details>, named groups group/proj + group/wi cho chevron. */} 3 layer <details>, named groups group/year + group/proj + group/wi cho chevron rotation. */}
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{projectGroups.map(pg => { {yearGroups.map(yg => {
const projKey = `${pg.projectId ?? '__no_project__'}::y${pg.year}` const yearKey = `y${yg.year}`
return ( return (
<details <details
key={projKey} key={yearKey}
open={isExpanded(projKey)} open={isExpanded(yearKey)}
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)} onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
className="group/proj" 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"> <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/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> <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="text-base">📅</span>
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.label}</span> <span className="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">{pg.totalCount}</span> <span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{yg.totalCount}</span>
</summary> </summary>
<div className="ml-3 border-l border-slate-200"> <div className="ml-3 border-l border-slate-200">
{pg.workItems.map(wg => { {yg.projects.map(pg => {
const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}` const projKey = `${yearKey}::p${pg.projectId ?? '_none_'}`
return ( return (
<details <details
key={wiKey} key={projKey}
open={isExpanded(wiKey)} open={isExpanded(projKey)}
onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)} onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
className="group/wi" 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"> <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> <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="text-sm">📁</span>
<span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span> <span className="flex-1 truncate text-[12px] font-medium text-slate-700">{pg.projectName}</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span> <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{pg.totalCount}</span>
</summary> </summary>
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200"> <div className="ml-3 border-l border-slate-200">
{wg.items.map(p => ( {pg.workItems.map(wg => {
<li key={p.id}> const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}`
<button return (
onClick={() => selectRow(p.id)} <details
className={cn( key={wiKey}
'block w-full px-3 py-2 text-left transition hover:bg-slate-50', open={isExpanded(wiKey)}
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)}
)} className="group/wi"
> >
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */} <summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
<div className="flex items-start justify-between gap-2"> <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>
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div> <span className="text-sm">🧱</span>
<span <span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
className={cn( <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', </summary>
PeDisplayStatusColor[getPeDisplayStatus(p.phase)], <ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
)} {wg.items.map(p => (
> <li key={p.id}>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} <button
</span> onClick={() => selectRow(p.id)}
</div> className={cn(
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> 'block w-full px-3 py-2 text-left transition hover:bg-slate-50',
<span className="font-mono">{p.maPhieu ?? '—'}</span> selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
<span className="text-slate-300">·</span> )}
{/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt). */} >
<span title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}> {/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
{new Date(p.createdAt).toLocaleString('vi-VN', { <div className="flex items-start justify-between gap-2">
day: '2-digit', month: '2-digit', year: 'numeric', <div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
hour: '2-digit', minute: '2-digit', <span
})} className={cn(
</span> 'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
</div> PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
{(p.drafterName || p.departmentName || p.contractId) && ( )}
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px]"> >
<span className="min-w-0 flex-1 truncate text-slate-500"> {PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
{p.drafterName && <>👤 {p.drafterName}</>} </span>
{p.drafterName && p.departmentName && <span className="text-slate-300"> · </span>} </div>
{p.departmentName && <span className="text-slate-400">{p.departmentName}</span>} <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
</span> <span className="font-mono">{p.maPhieu ?? '—'}</span>
{p.contractId && <span className="shrink-0 text-[10px] font-medium text-brand-600"> </span>} <span className="text-slate-300">·</span>
</div> {/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt). */}
)} <span title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
</button> {new Date(p.createdAt).toLocaleString('vi-VN', {
</li> day: '2-digit', month: '2-digit', year: 'numeric',
))} hour: '2-digit', minute: '2-digit',
</ul> })}
</span>
</div>
{(p.drafterName || p.departmentName || p.contractId) && (
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 flex-1 truncate text-slate-500">
{p.drafterName && <>👤 {p.drafterName}</>}
{p.drafterName && p.departmentName && <span className="text-slate-300"> · </span>}
{p.departmentName && <span className="text-slate-400">{p.departmentName}</span>}
</span>
{p.contractId && <span className="shrink-0 text-[10px] font-medium text-brand-600"> </span>}
</div>
)}
</button>
</li>
))}
</ul>
</details>
)
})}
</div>
</details> </details>
) )
})} })}