[CLAUDE] FE-User+FE-Admin: Plan AG5 — PE List tree 3-level Project > Năm > NCC > PE
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m32s

Anh feedback 2026-05-21: "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC nhé".
Plan AG3 chỉ 1-level Project > PE. Plan AG5 extend xuống 3 cấp: Năm + NCC.

Group structure:
- Level 1: 📁 Project (bg-slate-50, font-medium 13px)
- Level 2: 📅 Năm {year} (border-l ml-3, 12px)
- Level 3: 🏢 NCC (border-l ml-3, 12px, italic slate-400 nếu "Chưa chọn NCC")
- Leaf: PE card (border-l ml-3, giữ nguyên content)

Sort:
- Project A-Z (vi locale)
- Năm DESC (2026 trước 2025)
- NCC A-Z (vi locale)
- PE within NCC: createdAt DESC

Fallback:
- empty projectName → "(Dự án đã xoá)"
- selectedSupplierName null (PE chưa DaDuyet) → "(Chưa chọn NCC)" group + italic style

Drop redundant selectedSupplierName line trong PE card (đã hiện ở NCC group header).

localStorage keys:
- Project: projectId
- Năm: `${projectId}::y${year}`
- NCC: `${projectId}::y${year}::s${supplierId|'_none_'}`

Verify:
- npm build fe-user PASS 0 TS err 1292.68 KB (gzip 337.18 KB) 1907 modules
- npm build fe-admin PASS 0 TS err 1404.02 KB (gzip 357.70 KB) 1926 modules
- 2 file SHA256 IDENTICAL E5FE4979... (mirror §3.9)
- KHÔNG BE change, KHÔNG Mig, KHÔNG test (UAT mode)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-21 22:41:58 +07:00
parent 2bf01184ca
commit 083b601ea4
2 changed files with 274 additions and 136 deletions

View File

@ -115,13 +115,25 @@ 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+.
// Plan AG2 — Group 1-level Project > PE (drop tầng gói thầu per anh feedback 2026-05-21: // Plan AG5 — Group 3-level Project > Năm > NCC > PE (anh feedback 2026-05-21:
// "gói thầu thì ko cần thiết phải treedow"). Filter (pendingMe → DaGuiDuyet) TRƯỚC group. // "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC"). Filter pendingMe TRƯỚC group.
// Fallback "(Dự án đã xoá)" empty projectName. Sort vi locale Project A-Z + PE createdAt DESC. // Year extract từ createdAt.getFullYear(). NCC = selectedSupplierName fallback "(Chưa chọn NCC)"
// khi PE chưa DaDuyet. Sort: Project A-Z (vi) + Year DESC + NCC A-Z (vi) + PE createdAt DESC.
type SupplierGroup = {
supplierId: string | null
supplierName: string
items: PeListItem[]
}
type YearGroup = {
year: number
suppliers: SupplierGroup[]
totalCount: number
}
type ProjectGroup = { type ProjectGroup = {
projectId: string | null projectId: string | null
projectName: string projectName: string
items: PeListItem[] years: YearGroup[]
totalCount: number
} }
const projectGroups = useMemo<ProjectGroup[]>(() => { const projectGroups = useMemo<ProjectGroup[]>(() => {
const filtered = pendingMe const filtered = pendingMe
@ -132,20 +144,42 @@ export function PurchaseEvaluationsListPage() {
const projKey = p.projectId ?? '__no_project__' const projKey = p.projectId ?? '__no_project__'
const projName = p.projectName?.trim() || '(Dự án đã xoá)' const projName = p.projectName?.trim() || '(Dự án đã xoá)'
if (!projectMap.has(projKey)) { if (!projectMap.has(projKey)) {
projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, items: [] }) projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, years: [], totalCount: 0 })
} }
projectMap.get(projKey)!.items.push(p) const pg = projectMap.get(projKey)!
const year = new Date(p.createdAt).getFullYear()
let yg = pg.years.find(y => y.year === year)
if (!yg) {
yg = { year, suppliers: [], totalCount: 0 }
pg.years.push(yg)
}
const supKey = p.selectedSupplierId ?? '__no_supplier__'
const supName = p.selectedSupplierName?.trim() || '(Chưa chọn NCC)'
let sg = yg.suppliers.find(s => (s.supplierId ?? '__no_supplier__') === supKey)
if (!sg) {
sg = { supplierId: p.selectedSupplierId ?? null, supplierName: supName, items: [] }
yg.suppliers.push(sg)
}
sg.items.push(p)
yg.totalCount++
pg.totalCount++
} }
const arr = Array.from(projectMap.values()) const arr = Array.from(projectMap.values())
arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi')) arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi'))
for (const pg of arr) { for (const pg of arr) {
pg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) pg.years.sort((a, b) => b.year - a.year)
for (const yg of pg.years) {
yg.suppliers.sort((a, b) => a.supplierName.localeCompare(b.supplierName, 'vi'))
for (const sg of yg.suppliers) {
sg.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.items.length, 0) const totalRowCount = projectGroups.reduce((sum, pg) => sum + pg.totalCount, 0)
// Plan AG2 — Expand state localStorage Set<string> (projectId only, drop ::gtKey suffix). // Plan AG2 — Expand state localStorage Set<string> (projectId only, drop ::gtKey suffix).
// Default empty Set (all collapse). Single-PE project skip <details> wrapper (render flat). // Default empty Set (all collapse). Single-PE project skip <details> wrapper (render flat).
@ -251,10 +285,10 @@ export function PurchaseEvaluationsListPage() {
<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>
)} )}
{/* Plan AG3 — Tree view 1-level Project > PE consistent (anh feedback 2026-05-21: {/* Plan AG5 — Tree view 3-level Project > Năm > NCC > PE (anh feedback 2026-05-21:
"nếu có 1 thì cũng để tương tự luôn nhé, đừng để khác các thằng kia"). "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC").
Mọi dự án dù có 1 hay nhiều PE đều render <details> folder collapsed. 3 layer <details>: 📁 Project (bg-slate-50) > 📅 Năm > 🏢 NCC > PE card.
Tailwind v3 named group group/proj + [&::-webkit-details-marker]:hidden. */} Tailwind v3 named groups group/proj + group/year + group/sup cho chevron rotation. */}
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{projectGroups.map(pg => { {projectGroups.map(pg => {
const projKey = pg.projectId ?? '__no_project__' const projKey = pg.projectId ?? '__no_project__'
@ -269,65 +303,100 @@ export function PurchaseEvaluationsListPage() {
<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/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-base">📁</span> <span className="text-base">📁</span>
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span> <span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.items.length}</span> <span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{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">
{pg.items.map(p => ( {pg.years.map(yg => {
<li key={p.id}> const yearKey = `${projKey}::y${yg.year}`
<button return (
onClick={() => selectRow(p.id)} <details
className={cn( key={yearKey}
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50', open={isExpanded(yearKey)}
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
)} className="group/year"
> >
<div className="flex items-start justify-between gap-2"> <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="min-w-0 flex-1"> <svg className="h-3 w-3 shrink-0 text-slate-400 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>
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div> <span className="text-sm">📅</span>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <span className="flex-1 truncate text-[12px] font-medium text-slate-700">Năm {yg.year}</span>
<span className="font-mono">{p.maPhieu ?? '—'}</span> <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{yg.totalCount}</span>
</div> </summary>
{(p.drafterName || p.departmentName) && ( <div className="ml-3 border-l border-slate-200">
<div className="mt-0.5 truncate text-[11px] text-slate-500"> {yg.suppliers.map(sg => {
<span>👤 {p.drafterName ?? ''}</span> const supKey = `${yearKey}::s${sg.supplierId ?? '_none_'}`
{p.departmentName && <span className="text-slate-400"> · {p.departmentName}</span>} return (
</div> <details
)} key={supKey}
{p.selectedSupplierName && ( open={isExpanded(supKey)}
<div className="mt-0.5 truncate text-[11px] text-emerald-600"> onToggle={e => toggleExpand(supKey, (e.currentTarget as HTMLDetailsElement).open)}
{p.selectedSupplierName} className="group/sup"
</div> >
)} <summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
</div> <svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/sup: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 <span className="text-sm">🏢</span>
className={cn( <span className={cn('flex-1 truncate text-[12px]', sg.supplierId ? 'text-slate-700' : 'italic text-slate-400')}>{sg.supplierName}</span>
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{sg.items.length}</span>
PeDisplayStatusColor[getPeDisplayStatus(p.phase)], </summary>
)} <ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
> {sg.items.map(p => (
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} <li key={p.id}>
</span> <button
onClick={() => selectRow(p.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
</div>
{(p.drafterName || p.departmentName) && (
<div className="mt-0.5 truncate text-[11px] text-slate-500">
<span>👤 {p.drafterName ?? '—'}</span>
{p.departmentName && <span className="text-slate-400"> · {p.departmentName}</span>}
</div>
)}
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
)}
>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
{PurchaseEvaluationTypeLabel[p.type]}
</span>
{/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt) —
phiếu vừa update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */}
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</details>
)
})}
</div> </div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500"> </details>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600"> )
{PurchaseEvaluationTypeLabel[p.type]} })}
</span> </div>
{/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt) —
phiếu vừa update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */}
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</details> </details>
) )
})} })}

View File

@ -115,13 +115,25 @@ 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+.
// Plan AG2 — Group 1-level Project > PE (drop tầng gói thầu per anh feedback 2026-05-21: // Plan AG5 — Group 3-level Project > Năm > NCC > PE (anh feedback 2026-05-21:
// "gói thầu thì ko cần thiết phải treedow"). Filter (pendingMe → DaGuiDuyet) TRƯỚC group. // "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC"). Filter pendingMe TRƯỚC group.
// Fallback "(Dự án đã xoá)" empty projectName. Sort vi locale Project A-Z + PE createdAt DESC. // Year extract từ createdAt.getFullYear(). NCC = selectedSupplierName fallback "(Chưa chọn NCC)"
// khi PE chưa DaDuyet. Sort: Project A-Z (vi) + Year DESC + NCC A-Z (vi) + PE createdAt DESC.
type SupplierGroup = {
supplierId: string | null
supplierName: string
items: PeListItem[]
}
type YearGroup = {
year: number
suppliers: SupplierGroup[]
totalCount: number
}
type ProjectGroup = { type ProjectGroup = {
projectId: string | null projectId: string | null
projectName: string projectName: string
items: PeListItem[] years: YearGroup[]
totalCount: number
} }
const projectGroups = useMemo<ProjectGroup[]>(() => { const projectGroups = useMemo<ProjectGroup[]>(() => {
const filtered = pendingMe const filtered = pendingMe
@ -132,20 +144,42 @@ export function PurchaseEvaluationsListPage() {
const projKey = p.projectId ?? '__no_project__' const projKey = p.projectId ?? '__no_project__'
const projName = p.projectName?.trim() || '(Dự án đã xoá)' const projName = p.projectName?.trim() || '(Dự án đã xoá)'
if (!projectMap.has(projKey)) { if (!projectMap.has(projKey)) {
projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, items: [] }) projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, years: [], totalCount: 0 })
} }
projectMap.get(projKey)!.items.push(p) const pg = projectMap.get(projKey)!
const year = new Date(p.createdAt).getFullYear()
let yg = pg.years.find(y => y.year === year)
if (!yg) {
yg = { year, suppliers: [], totalCount: 0 }
pg.years.push(yg)
}
const supKey = p.selectedSupplierId ?? '__no_supplier__'
const supName = p.selectedSupplierName?.trim() || '(Chưa chọn NCC)'
let sg = yg.suppliers.find(s => (s.supplierId ?? '__no_supplier__') === supKey)
if (!sg) {
sg = { supplierId: p.selectedSupplierId ?? null, supplierName: supName, items: [] }
yg.suppliers.push(sg)
}
sg.items.push(p)
yg.totalCount++
pg.totalCount++
} }
const arr = Array.from(projectMap.values()) const arr = Array.from(projectMap.values())
arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi')) arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi'))
for (const pg of arr) { for (const pg of arr) {
pg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) pg.years.sort((a, b) => b.year - a.year)
for (const yg of pg.years) {
yg.suppliers.sort((a, b) => a.supplierName.localeCompare(b.supplierName, 'vi'))
for (const sg of yg.suppliers) {
sg.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.items.length, 0) const totalRowCount = projectGroups.reduce((sum, pg) => sum + pg.totalCount, 0)
// Plan AG2 — Expand state localStorage Set<string> (projectId only, drop ::gtKey suffix). // Plan AG2 — Expand state localStorage Set<string> (projectId only, drop ::gtKey suffix).
// Default empty Set (all collapse). Single-PE project skip <details> wrapper (render flat). // Default empty Set (all collapse). Single-PE project skip <details> wrapper (render flat).
@ -251,10 +285,10 @@ export function PurchaseEvaluationsListPage() {
<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>
)} )}
{/* Plan AG3 — Tree view 1-level Project > PE consistent (anh feedback 2026-05-21: {/* Plan AG5 — Tree view 3-level Project > Năm > NCC > PE (anh feedback 2026-05-21:
"nếu có 1 thì cũng để tương tự luôn nhé, đừng để khác các thằng kia"). "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC").
Mọi dự án dù có 1 hay nhiều PE đều render <details> folder collapsed. 3 layer <details>: 📁 Project (bg-slate-50) > 📅 Năm > 🏢 NCC > PE card.
Tailwind v3 named group group/proj + [&::-webkit-details-marker]:hidden. */} Tailwind v3 named groups group/proj + group/year + group/sup cho chevron rotation. */}
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{projectGroups.map(pg => { {projectGroups.map(pg => {
const projKey = pg.projectId ?? '__no_project__' const projKey = pg.projectId ?? '__no_project__'
@ -269,65 +303,100 @@ export function PurchaseEvaluationsListPage() {
<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/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-base">📁</span> <span className="text-base">📁</span>
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span> <span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.items.length}</span> <span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{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">
{pg.items.map(p => ( {pg.years.map(yg => {
<li key={p.id}> const yearKey = `${projKey}::y${yg.year}`
<button return (
onClick={() => selectRow(p.id)} <details
className={cn( key={yearKey}
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50', open={isExpanded(yearKey)}
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
)} className="group/year"
> >
<div className="flex items-start justify-between gap-2"> <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="min-w-0 flex-1"> <svg className="h-3 w-3 shrink-0 text-slate-400 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>
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div> <span className="text-sm">📅</span>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <span className="flex-1 truncate text-[12px] font-medium text-slate-700">Năm {yg.year}</span>
<span className="font-mono">{p.maPhieu ?? '—'}</span> <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{yg.totalCount}</span>
</div> </summary>
{(p.drafterName || p.departmentName) && ( <div className="ml-3 border-l border-slate-200">
<div className="mt-0.5 truncate text-[11px] text-slate-500"> {yg.suppliers.map(sg => {
<span>👤 {p.drafterName ?? ''}</span> const supKey = `${yearKey}::s${sg.supplierId ?? '_none_'}`
{p.departmentName && <span className="text-slate-400"> · {p.departmentName}</span>} return (
</div> <details
)} key={supKey}
{p.selectedSupplierName && ( open={isExpanded(supKey)}
<div className="mt-0.5 truncate text-[11px] text-emerald-600"> onToggle={e => toggleExpand(supKey, (e.currentTarget as HTMLDetailsElement).open)}
{p.selectedSupplierName} className="group/sup"
</div> >
)} <summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
</div> <svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/sup: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 <span className="text-sm">🏢</span>
className={cn( <span className={cn('flex-1 truncate text-[12px]', sg.supplierId ? 'text-slate-700' : 'italic text-slate-400')}>{sg.supplierName}</span>
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{sg.items.length}</span>
PeDisplayStatusColor[getPeDisplayStatus(p.phase)], </summary>
)} <ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
> {sg.items.map(p => (
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} <li key={p.id}>
</span> <button
onClick={() => selectRow(p.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
</div>
{(p.drafterName || p.departmentName) && (
<div className="mt-0.5 truncate text-[11px] text-slate-500">
<span>👤 {p.drafterName ?? '—'}</span>
{p.departmentName && <span className="text-slate-400"> · {p.departmentName}</span>}
</div>
)}
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
)}
>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
{PurchaseEvaluationTypeLabel[p.type]}
</span>
{/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt) —
phiếu vừa update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */}
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</details>
)
})}
</div> </div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500"> </details>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600"> )
{PurchaseEvaluationTypeLabel[p.type]} })}
</span> </div>
{/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt) —
phiếu vừa update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */}
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</details> </details>
) )
})} })}