[CLAUDE] FE-User+FE-Admin: Plan AG2 — Simplify PE List tree 1-level + Panel 1 widen 400px
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m29s
Anh feedback Plan AG (2-level Project > Gói thầu > PE) cầu kỳ quá. Simplify xuống 1-level + widen panel cho dễ đọc. 3 changes: 1. Panel 1 widen 340px → 400px (lg:grid-cols-[400px_1fr_360px]) 2. Drop GoiThauGroup nested type + inner <details> tree, useMemo group 1-level Project > PE[]; PE sort by createdAt DESC trong group (mirror BE sort) 3. Smart render: single-PE project → flat card (no <details> wrapper, project name UPPERCASE label inline) / multi-PE project → <details> tree expand 4. localStorage key rename 'pe_list_expanded_projects' (drop ::gtKey composite suffix) UAT visual: dự án solo PE hiện flat (không cần click expand), dự án có nhiều phiếu render tree compact. Drop redundant projectName ở PE card (đã có ở group header / UPPERCASE label). Verify: - npm build fe-user PASS 0 TS err 1291.76 KB (gzip 336.90 KB) 1907 modules - npm build fe-admin PASS 0 TS err 1403.10 KB (gzip 357.41 KB) 1926 modules - 2 file SHA256 IDENTICAL 37520D01... (mirror §3.9) - KHÔNG BE change, KHÔNG Mig, KHÔNG test (UAT mode per feedback_uat_skip_verify) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -115,21 +115,13 @@ 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 AG Chunk A — Data transform useMemo group nested 2-level Project > Gói thầu
|
// Plan AG2 — Group 1-level Project > PE (drop tầng gói thầu per anh feedback 2026-05-21:
|
||||||
// Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group để empty state đúng.
|
// "gói thầu thì ko cần thiết phải treedow"). Filter (pendingMe → DaGuiDuyet) TRƯỚC group.
|
||||||
// Normalize TenGoiThau: trim + toLowerCase làm group key, display raw đầu tiên.
|
// Fallback "(Dự án đã xoá)" empty projectName. Sort vi locale Project A-Z + PE createdAt DESC.
|
||||||
// Fallback: "(Dự án đã xoá)" empty projectName + "(Chưa phân loại)" empty TenGoiThau.
|
|
||||||
// Sort vi locale A-Z cả 2 cấp.
|
|
||||||
type GoiThauGroup = {
|
|
||||||
displayName: string
|
|
||||||
normalizedKey: string
|
|
||||||
items: PeListItem[]
|
|
||||||
}
|
|
||||||
type ProjectGroup = {
|
type ProjectGroup = {
|
||||||
projectId: string | null
|
projectId: string | null
|
||||||
projectName: string
|
projectName: string
|
||||||
goiThauList: GoiThauGroup[]
|
items: PeListItem[]
|
||||||
totalCount: number
|
|
||||||
}
|
}
|
||||||
const projectGroups = useMemo<ProjectGroup[]>(() => {
|
const projectGroups = useMemo<ProjectGroup[]>(() => {
|
||||||
const filtered = pendingMe
|
const filtered = pendingMe
|
||||||
@ -140,35 +132,24 @@ 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, goiThauList: [], totalCount: 0 })
|
projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, items: [] })
|
||||||
}
|
}
|
||||||
const pg = projectMap.get(projKey)!
|
projectMap.get(projKey)!.items.push(p)
|
||||||
const rawGT = (p.tenGoiThau ?? '').trim()
|
|
||||||
const normKey = rawGT.toLowerCase() || '__no_goithau__'
|
|
||||||
const displayGT = rawGT || '(Chưa phân loại)'
|
|
||||||
let gt = pg.goiThauList.find(g => g.normalizedKey === normKey)
|
|
||||||
if (!gt) {
|
|
||||||
gt = { displayName: displayGT, normalizedKey: normKey, items: [] }
|
|
||||||
pg.goiThauList.push(gt)
|
|
||||||
}
|
|
||||||
gt.items.push(p)
|
|
||||||
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.goiThauList.sort((a, b) => a.displayName.localeCompare(b.displayName, 'vi'))
|
pg.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 = projectGroups.reduce((sum, pg) => sum + pg.items.length, 0)
|
||||||
|
|
||||||
// Plan AG Chunk C — Expand state localStorage persist Set<string>
|
// Plan AG2 — Expand state localStorage Set<string> (projectId only, drop ::gtKey suffix).
|
||||||
// Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default.
|
// Default empty Set (all collapse). Single-PE project skip <details> wrapper (render flat).
|
||||||
// Project key: projectId or '__no_project__'; Gói thầu key: `${projectId}::${normalizedGoiThau}`.
|
const STORAGE_KEY = 'pe_list_expanded_projects'
|
||||||
const STORAGE_KEY = 'pe_list_expanded_groups'
|
|
||||||
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)
|
||||||
@ -204,7 +185,7 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
||||||
{/* Panel 1: List */}
|
{/* Panel 1: List */}
|
||||||
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
<div className="space-y-2 border-b border-slate-200 p-3">
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||||
@ -270,100 +251,135 @@ 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 AG Chunk B — Tree view 2-level Project > Gói thầu > PE (Outlook-style).
|
{/* Plan AG2 — Tree view 1-level Project > PE (drop gói thầu per anh 2026-05-21).
|
||||||
<details>/<summary> HTML native (no shadcn Accordion — gap component lib fe-user).
|
<details>/<summary> HTML native. Single-PE project → render flat card no wrapper.
|
||||||
Tailwind v3 named groups group/proj + group/gt cho chevron rotation animation.
|
Multi-PE project → <details> tree với toggle expand + localStorage persist.
|
||||||
[&::-webkit-details-marker]:hidden ẩn default disclosure triangle browser. */}
|
Tailwind v3 named group group/proj + [&::-webkit-details-marker]:hidden. */}
|
||||||
<div className="divide-y divide-slate-100">
|
<div className="divide-y divide-slate-100">
|
||||||
{projectGroups.map(pg => (
|
{projectGroups.map(pg => {
|
||||||
<details
|
const projKey = pg.projectId ?? '__no_project__'
|
||||||
key={pg.projectId ?? '__no_project__'}
|
// Single-PE project → render flat card (no <details> wrapper) — anh feedback "ko cần treedow"
|
||||||
open={isExpanded(pg.projectId ?? '__no_project__')}
|
if (pg.items.length === 1) {
|
||||||
onToggle={e => toggleExpand(pg.projectId ?? '__no_project__', (e.currentTarget as HTMLDetailsElement).open)}
|
const p = pg.items[0]
|
||||||
className="group/proj"
|
return (
|
||||||
>
|
<button
|
||||||
<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">
|
key={p.id}
|
||||||
<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>
|
onClick={() => selectRow(p.id)}
|
||||||
<span className="text-base">📁</span>
|
className={cn(
|
||||||
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||||
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.totalCount}</span>
|
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||||
</summary>
|
)}
|
||||||
<div className="ml-3 border-l border-slate-200">
|
>
|
||||||
{pg.goiThauList.map(gt => {
|
<div className="mb-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-slate-400">
|
||||||
const gtKey = `${pg.projectId ?? '__no_project__'}::${gt.normalizedKey}`
|
<span>📁</span>
|
||||||
return (
|
<span className="truncate">{pg.projectName}</span>
|
||||||
<details
|
</div>
|
||||||
key={gtKey}
|
<div className="flex items-start justify-between gap-2">
|
||||||
open={isExpanded(gtKey)}
|
<div className="min-w-0 flex-1">
|
||||||
onToggle={e => toggleExpand(gtKey, (e.currentTarget as HTMLDetailsElement).open)}
|
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
|
||||||
className="group/gt"
|
<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.selectedSupplierName && (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
||||||
|
✓ {p.selectedSupplierName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
|
||||||
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/gt: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>
|
</div>
|
||||||
<span className="flex-1 truncate text-[12px] text-slate-700">{gt.displayName}</span>
|
<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-[10px] text-slate-600">{gt.items.length}</span>
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||||
</summary>
|
{PurchaseEvaluationTypeLabel[p.type]}
|
||||||
<ul className="divide-y divide-slate-100">
|
</span>
|
||||||
{gt.items.map(p => (
|
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
|
||||||
<li key={p.id}>
|
{new Date(p.createdAt).toLocaleString('vi-VN', {
|
||||||
<button
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
onClick={() => selectRow(p.id)}
|
hour: '2-digit', minute: '2-digit',
|
||||||
className={cn(
|
})}
|
||||||
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
</span>
|
||||||
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
</div>
|
||||||
)}
|
{p.contractId && (
|
||||||
>
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
||||||
<div className="flex items-start justify-between gap-2">
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
</button>
|
||||||
<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>
|
// Multi-PE project → render <details> tree
|
||||||
<span>·</span>
|
return (
|
||||||
<span className="truncate">{p.projectName}</span>
|
<details
|
||||||
</div>
|
key={projKey}
|
||||||
{p.selectedSupplierName && (
|
open={isExpanded(projKey)}
|
||||||
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
✓ {p.selectedSupplierName}
|
className="group/proj"
|
||||||
</div>
|
>
|
||||||
)}
|
<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">
|
||||||
</div>
|
<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
|
<span className="text-base">📁</span>
|
||||||
className={cn(
|
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
|
||||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.items.length}</span>
|
||||||
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
|
</summary>
|
||||||
)}
|
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
|
||||||
>
|
{pg.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.selectedSupplierName && (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
||||||
|
✓ {p.selectedSupplierName}
|
||||||
</div>
|
</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">
|
</div>
|
||||||
{PurchaseEvaluationTypeLabel[p.type]}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
{/* S23 t2 UAT: bro yêu cầu đổi SLA countdown → ngày giờ tạo phiếu.
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
BE list sort theo UpdatedAt DESC (fallback CreatedAt) — phiếu vừa
|
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
|
||||||
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', {
|
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
</span>
|
||||||
hour: '2-digit', minute: '2-digit',
|
</div>
|
||||||
})}
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
</span>
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||||
</div>
|
{PurchaseEvaluationTypeLabel[p.type]}
|
||||||
{p.contractId && (
|
</span>
|
||||||
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</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. */}
|
||||||
</button>
|
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
|
||||||
</li>
|
{new Date(p.createdAt).toLocaleString('vi-VN', {
|
||||||
))}
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
</ul>
|
hour: '2-digit', minute: '2-digit',
|
||||||
</details>
|
})}
|
||||||
)
|
</span>
|
||||||
})}
|
</div>
|
||||||
</div>
|
{p.contractId && (
|
||||||
</details>
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
||||||
))}
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@ -115,21 +115,13 @@ 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 AG Chunk A — Data transform useMemo group nested 2-level Project > Gói thầu
|
// Plan AG2 — Group 1-level Project > PE (drop tầng gói thầu per anh feedback 2026-05-21:
|
||||||
// Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group để empty state đúng.
|
// "gói thầu thì ko cần thiết phải treedow"). Filter (pendingMe → DaGuiDuyet) TRƯỚC group.
|
||||||
// Normalize TenGoiThau: trim + toLowerCase làm group key, display raw đầu tiên.
|
// Fallback "(Dự án đã xoá)" empty projectName. Sort vi locale Project A-Z + PE createdAt DESC.
|
||||||
// Fallback: "(Dự án đã xoá)" empty projectName + "(Chưa phân loại)" empty TenGoiThau.
|
|
||||||
// Sort vi locale A-Z cả 2 cấp.
|
|
||||||
type GoiThauGroup = {
|
|
||||||
displayName: string
|
|
||||||
normalizedKey: string
|
|
||||||
items: PeListItem[]
|
|
||||||
}
|
|
||||||
type ProjectGroup = {
|
type ProjectGroup = {
|
||||||
projectId: string | null
|
projectId: string | null
|
||||||
projectName: string
|
projectName: string
|
||||||
goiThauList: GoiThauGroup[]
|
items: PeListItem[]
|
||||||
totalCount: number
|
|
||||||
}
|
}
|
||||||
const projectGroups = useMemo<ProjectGroup[]>(() => {
|
const projectGroups = useMemo<ProjectGroup[]>(() => {
|
||||||
const filtered = pendingMe
|
const filtered = pendingMe
|
||||||
@ -140,35 +132,24 @@ 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, goiThauList: [], totalCount: 0 })
|
projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, items: [] })
|
||||||
}
|
}
|
||||||
const pg = projectMap.get(projKey)!
|
projectMap.get(projKey)!.items.push(p)
|
||||||
const rawGT = (p.tenGoiThau ?? '').trim()
|
|
||||||
const normKey = rawGT.toLowerCase() || '__no_goithau__'
|
|
||||||
const displayGT = rawGT || '(Chưa phân loại)'
|
|
||||||
let gt = pg.goiThauList.find(g => g.normalizedKey === normKey)
|
|
||||||
if (!gt) {
|
|
||||||
gt = { displayName: displayGT, normalizedKey: normKey, items: [] }
|
|
||||||
pg.goiThauList.push(gt)
|
|
||||||
}
|
|
||||||
gt.items.push(p)
|
|
||||||
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.goiThauList.sort((a, b) => a.displayName.localeCompare(b.displayName, 'vi'))
|
pg.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 = projectGroups.reduce((sum, pg) => sum + pg.items.length, 0)
|
||||||
|
|
||||||
// Plan AG Chunk C — Expand state localStorage persist Set<string>
|
// Plan AG2 — Expand state localStorage Set<string> (projectId only, drop ::gtKey suffix).
|
||||||
// Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default.
|
// Default empty Set (all collapse). Single-PE project skip <details> wrapper (render flat).
|
||||||
// Project key: projectId or '__no_project__'; Gói thầu key: `${projectId}::${normalizedGoiThau}`.
|
const STORAGE_KEY = 'pe_list_expanded_projects'
|
||||||
const STORAGE_KEY = 'pe_list_expanded_groups'
|
|
||||||
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)
|
||||||
@ -204,7 +185,7 @@ export function PurchaseEvaluationsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
||||||
{/* Panel 1: List */}
|
{/* Panel 1: List */}
|
||||||
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
<div className="space-y-2 border-b border-slate-200 p-3">
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||||
@ -270,100 +251,135 @@ 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 AG Chunk B — Tree view 2-level Project > Gói thầu > PE (Outlook-style).
|
{/* Plan AG2 — Tree view 1-level Project > PE (drop gói thầu per anh 2026-05-21).
|
||||||
<details>/<summary> HTML native (no shadcn Accordion — gap component lib fe-user).
|
<details>/<summary> HTML native. Single-PE project → render flat card no wrapper.
|
||||||
Tailwind v3 named groups group/proj + group/gt cho chevron rotation animation.
|
Multi-PE project → <details> tree với toggle expand + localStorage persist.
|
||||||
[&::-webkit-details-marker]:hidden ẩn default disclosure triangle browser. */}
|
Tailwind v3 named group group/proj + [&::-webkit-details-marker]:hidden. */}
|
||||||
<div className="divide-y divide-slate-100">
|
<div className="divide-y divide-slate-100">
|
||||||
{projectGroups.map(pg => (
|
{projectGroups.map(pg => {
|
||||||
<details
|
const projKey = pg.projectId ?? '__no_project__'
|
||||||
key={pg.projectId ?? '__no_project__'}
|
// Single-PE project → render flat card (no <details> wrapper) — anh feedback "ko cần treedow"
|
||||||
open={isExpanded(pg.projectId ?? '__no_project__')}
|
if (pg.items.length === 1) {
|
||||||
onToggle={e => toggleExpand(pg.projectId ?? '__no_project__', (e.currentTarget as HTMLDetailsElement).open)}
|
const p = pg.items[0]
|
||||||
className="group/proj"
|
return (
|
||||||
>
|
<button
|
||||||
<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">
|
key={p.id}
|
||||||
<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>
|
onClick={() => selectRow(p.id)}
|
||||||
<span className="text-base">📁</span>
|
className={cn(
|
||||||
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||||
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.totalCount}</span>
|
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||||
</summary>
|
)}
|
||||||
<div className="ml-3 border-l border-slate-200">
|
>
|
||||||
{pg.goiThauList.map(gt => {
|
<div className="mb-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-slate-400">
|
||||||
const gtKey = `${pg.projectId ?? '__no_project__'}::${gt.normalizedKey}`
|
<span>📁</span>
|
||||||
return (
|
<span className="truncate">{pg.projectName}</span>
|
||||||
<details
|
</div>
|
||||||
key={gtKey}
|
<div className="flex items-start justify-between gap-2">
|
||||||
open={isExpanded(gtKey)}
|
<div className="min-w-0 flex-1">
|
||||||
onToggle={e => toggleExpand(gtKey, (e.currentTarget as HTMLDetailsElement).open)}
|
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
|
||||||
className="group/gt"
|
<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.selectedSupplierName && (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
||||||
|
✓ {p.selectedSupplierName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
|
||||||
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/gt: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>
|
</div>
|
||||||
<span className="flex-1 truncate text-[12px] text-slate-700">{gt.displayName}</span>
|
<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-[10px] text-slate-600">{gt.items.length}</span>
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||||
</summary>
|
{PurchaseEvaluationTypeLabel[p.type]}
|
||||||
<ul className="divide-y divide-slate-100">
|
</span>
|
||||||
{gt.items.map(p => (
|
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
|
||||||
<li key={p.id}>
|
{new Date(p.createdAt).toLocaleString('vi-VN', {
|
||||||
<button
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
onClick={() => selectRow(p.id)}
|
hour: '2-digit', minute: '2-digit',
|
||||||
className={cn(
|
})}
|
||||||
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
</span>
|
||||||
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
</div>
|
||||||
)}
|
{p.contractId && (
|
||||||
>
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
||||||
<div className="flex items-start justify-between gap-2">
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
</button>
|
||||||
<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>
|
// Multi-PE project → render <details> tree
|
||||||
<span>·</span>
|
return (
|
||||||
<span className="truncate">{p.projectName}</span>
|
<details
|
||||||
</div>
|
key={projKey}
|
||||||
{p.selectedSupplierName && (
|
open={isExpanded(projKey)}
|
||||||
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||||
✓ {p.selectedSupplierName}
|
className="group/proj"
|
||||||
</div>
|
>
|
||||||
)}
|
<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">
|
||||||
</div>
|
<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
|
<span className="text-base">📁</span>
|
||||||
className={cn(
|
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
|
||||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.items.length}</span>
|
||||||
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
|
</summary>
|
||||||
)}
|
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
|
||||||
>
|
{pg.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.selectedSupplierName && (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
||||||
|
✓ {p.selectedSupplierName}
|
||||||
</div>
|
</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">
|
</div>
|
||||||
{PurchaseEvaluationTypeLabel[p.type]}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
{/* S23 t2 UAT: bro yêu cầu đổi SLA countdown → ngày giờ tạo phiếu.
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
BE list sort theo UpdatedAt DESC (fallback CreatedAt) — phiếu vừa
|
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
|
||||||
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', {
|
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
</span>
|
||||||
hour: '2-digit', minute: '2-digit',
|
</div>
|
||||||
})}
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
</span>
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||||
</div>
|
{PurchaseEvaluationTypeLabel[p.type]}
|
||||||
{p.contractId && (
|
</span>
|
||||||
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</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. */}
|
||||||
</button>
|
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
|
||||||
</li>
|
{new Date(p.createdAt).toLocaleString('vi-VN', {
|
||||||
))}
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
</ul>
|
hour: '2-digit', minute: '2-digit',
|
||||||
</details>
|
})}
|
||||||
)
|
</span>
|
||||||
})}
|
</div>
|
||||||
</div>
|
{p.contractId && (
|
||||||
</details>
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
||||||
))}
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user