[CLAUDE] FE-User+FE-Admin: Plan AG Chunk A+B+C — PE List tree view 2-level Project > Gói thầu
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m39s

UAT feedback bro Tra Sol 2026-05-21: UI Duyệt NCC flat list "đám rừng" → tree view giống Outlook folder.

Phase 1 FE-only mirror 2 app §3.9 — KHÔNG schema mới (Phase 2 ProjectPackage defer sau UAT confirm).

Chunk A — Data transform useMemo group:
- Map<projectId, Map<normalizedGoiThau, PeListItem[]>>
- Normalize TenGoiThau: trim + toLowerCase, display raw đầu tiên trong group
- Sort: Project A-Z + gói thầu A-Z (vi locale)
- Fallback: "(Dự án đã xoá)" empty projectName + "(Chưa phân loại)" empty TenGoiThau
- Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group

Chunk B — UI render <details>/<summary> 2-level:
- Replace flat <ul><li> bằng nested <details> HTML native (no shadcn Accordion — gap component lib)
- 📁 Project + 📄 Gói thầu icon + count badge inline
- Chevron rotation via Tailwind group-open/proj + group-open/gt named groups
- PE card content preserve nguyên (line 209-248 unchanged)

Chunk C — Expand state localStorage persist:
- Key 'pe_list_expanded_groups' Set<string>
- Project level key: projectId; Gói thầu level key: ${projectId}::${normalizedGoiThau}
- Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default

Verify:
- npm build fe-user PASS 0 TS err 1291.33 KB (gzip 337.00 KB) 1907 modules 16.05s
- npm build fe-admin PASS 0 TS err 1402.68 KB (gzip 357.51 KB) 1926 modules 6.86s
- KHÔNG BE change, KHÔNG Mig, KHÔNG test (UAT mode per feedback_uat_skip_verify)

Pending: Reviewer pre-commit + CICD Run #222 verify

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-21 17:46:17 +07:00
parent 0c6efdaf4f
commit 0bf6c7ec63
2 changed files with 346 additions and 116 deletions

View File

@ -1,5 +1,9 @@
// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history. // List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search). // URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
// Plan AG Phase 1 (S26 2026-05-21) — Tree view 2-level Project > Gói thầu > PE
// UAT feedback bro Tra Sol: flat list "đám rừng" → Outlook folder structure.
// FE-only group view (no schema change) — Phase 2 ProjectPackage defer sau UAT confirm.
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -110,9 +114,79 @@ export function PurchaseEvaluationsListPage() {
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/ // Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
// Đã 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+.
const rows = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) // Plan AG Chunk A — Data transform useMemo group nested 2-level Project > Gói thầu
: allRows // Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group để empty state đúng.
// Normalize TenGoiThau: trim + toLowerCase làm group key, display raw đầu tiên.
// 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 = {
projectId: string | null
projectName: string
goiThauList: GoiThauGroup[]
totalCount: number
}
const projectGroups = useMemo<ProjectGroup[]>(() => {
const filtered = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
: allRows
const projectMap = new Map<string, ProjectGroup>()
for (const p of filtered) {
const projKey = p.projectId ?? '__no_project__'
const projName = p.projectName?.trim() || '(Dự án đã xoá)'
if (!projectMap.has(projKey)) {
projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, goiThauList: [], totalCount: 0 })
}
const pg = projectMap.get(projKey)!
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())
arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi'))
for (const pg of arr) {
pg.goiThauList.sort((a, b) => a.displayName.localeCompare(b.displayName, 'vi'))
}
return arr
}, [allRows, pendingMe])
// 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)
// Plan AG Chunk C — Expand state localStorage persist Set<string>
// Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default.
// Project key: projectId or '__no_project__'; Gói thầu key: `${projectId}::${normalizedGoiThau}`.
const STORAGE_KEY = 'pe_list_expanded_groups'
const [expandedSet, setExpandedSet] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? new Set(JSON.parse(raw) as string[]) : new Set<string>()
} catch {
return new Set<string>()
}
})
const isExpanded = (key: string) => expandedSet.has(key)
const toggleExpand = (key: string, open: boolean) => {
setExpandedSet(prev => {
const next = new Set(prev)
if (open) next.add(key)
else next.delete(key)
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) } catch {}
return next
})
}
const headerTitle = typeFilter const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter]) ? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
@ -125,7 +199,7 @@ export function PurchaseEvaluationsListPage() {
<ClipboardCheck className="h-5 w-5 text-slate-500" /> <ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1> <h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600"> <span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{pendingMe ? rows.length : (list.data?.total ?? 0)} {pendingMe ? totalRowCount : (list.data?.total ?? 0)}
</span> </span>
</div> </div>
</header> </header>
@ -191,65 +265,106 @@ export function PurchaseEvaluationsListPage() {
))} ))}
</div> </div>
)} )}
{!list.isLoading && rows.length === 0 && ( {!list.isLoading && projectGroups.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>
)} )}
<ul className="divide-y divide-slate-100"> {/* Plan AG Chunk B — Tree view 2-level Project > Gói thầu > PE (Outlook-style).
{rows.map(p => ( <details>/<summary> HTML native (no shadcn Accordion — gap component lib fe-user).
<li key={p.id}> Tailwind v3 named groups group/proj + group/gt cho chevron rotation animation.
<button [&::-webkit-details-marker]:hidden ẩn default disclosure triangle browser. */}
onClick={() => selectRow(p.id)} <div className="divide-y divide-slate-100">
className={cn( {projectGroups.map(pg => (
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50', <details
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', key={pg.projectId ?? '__no_project__'}
)} open={isExpanded(pg.projectId ?? '__no_project__')}
> onToggle={e => toggleExpand(pg.projectId ?? '__no_project__', (e.currentTarget as HTMLDetailsElement).open)}
<div className="flex items-start justify-between gap-2"> className="group/proj"
<div className="min-w-0 flex-1"> >
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</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 className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <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="font-mono">{p.maPhieu ?? '—'}</span> <span className="text-base">📁</span>
<span>·</span> <span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
<span className="truncate">{p.projectName}</span> <span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.totalCount}</span>
</div> </summary>
{p.selectedSupplierName && ( <div className="ml-3 border-l border-slate-200">
<div className="mt-0.5 truncate text-[11px] text-emerald-600"> {pg.goiThauList.map(gt => {
{p.selectedSupplierName} const gtKey = `${pg.projectId ?? '__no_project__'}::${gt.normalizedKey}`
</div> return (
)} <details
</div> key={gtKey}
<span open={isExpanded(gtKey)}
className={cn( onToggle={e => toggleExpand(gtKey, (e.currentTarget as HTMLDetailsElement).open)}
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', className="group/gt"
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">
> <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>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} <span className="text-sm">📄</span>
</span> <span className="flex-1 truncate text-[12px] text-slate-700">{gt.displayName}</span>
</div> <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{gt.items.length}</span>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500"> </summary>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600"> <ul className="divide-y divide-slate-100">
{PurchaseEvaluationTypeLabel[p.type]} {gt.items.map(p => (
</span> <li key={p.id}>
{/* S23 t2 UAT: bro yêu cầu đổi SLA countdown → ngày giờ tạo phiếu. <button
BE list sort theo UpdatedAt DESC (fallback CreatedAt) — phiếu vừa onClick={() => selectRow(p.id)}
update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */} className={cn(
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}> 'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
{new Date(p.createdAt).toLocaleString('vi-VN', { selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
day: '2-digit', month: '2-digit', year: 'numeric', )}
hour: '2-digit', minute: '2-digit', >
})} <div className="flex items-start justify-between gap-2">
</span> <div className="min-w-0 flex-1">
</div> <div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
{p.contractId && ( <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div> <span className="font-mono">{p.maPhieu ?? '—'}</span>
)} <span>·</span>
</button> <span className="truncate">{p.projectName}</span>
</li> </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)],
)}
>
{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: bro yêu cầu đổi SLA countdown → ngày giờ tạo phiếu.
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>
</details>
))} ))}
</ul> </div>
</div> </div>
</aside> </aside>

View File

@ -1,5 +1,9 @@
// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history. // List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search). // URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
// Plan AG Phase 1 (S26 2026-05-21) — Tree view 2-level Project > Gói thầu > PE
// UAT feedback bro Tra Sol: flat list "đám rừng" → Outlook folder structure.
// FE-only group view (no schema change) — Phase 2 ProjectPackage defer sau UAT confirm.
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -110,9 +114,79 @@ export function PurchaseEvaluationsListPage() {
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/ // Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
// Đã 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+.
const rows = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) // Plan AG Chunk A — Data transform useMemo group nested 2-level Project > Gói thầu
: allRows // Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group để empty state đúng.
// Normalize TenGoiThau: trim + toLowerCase làm group key, display raw đầu tiên.
// 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 = {
projectId: string | null
projectName: string
goiThauList: GoiThauGroup[]
totalCount: number
}
const projectGroups = useMemo<ProjectGroup[]>(() => {
const filtered = pendingMe
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
: allRows
const projectMap = new Map<string, ProjectGroup>()
for (const p of filtered) {
const projKey = p.projectId ?? '__no_project__'
const projName = p.projectName?.trim() || '(Dự án đã xoá)'
if (!projectMap.has(projKey)) {
projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, goiThauList: [], totalCount: 0 })
}
const pg = projectMap.get(projKey)!
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())
arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi'))
for (const pg of arr) {
pg.goiThauList.sort((a, b) => a.displayName.localeCompare(b.displayName, 'vi'))
}
return arr
}, [allRows, pendingMe])
// 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)
// Plan AG Chunk C — Expand state localStorage persist Set<string>
// Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default.
// Project key: projectId or '__no_project__'; Gói thầu key: `${projectId}::${normalizedGoiThau}`.
const STORAGE_KEY = 'pe_list_expanded_groups'
const [expandedSet, setExpandedSet] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? new Set(JSON.parse(raw) as string[]) : new Set<string>()
} catch {
return new Set<string>()
}
})
const isExpanded = (key: string) => expandedSet.has(key)
const toggleExpand = (key: string, open: boolean) => {
setExpandedSet(prev => {
const next = new Set(prev)
if (open) next.add(key)
else next.delete(key)
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) } catch {}
return next
})
}
const headerTitle = typeFilter const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter]) ? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
@ -125,7 +199,7 @@ export function PurchaseEvaluationsListPage() {
<ClipboardCheck className="h-5 w-5 text-slate-500" /> <ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1> <h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600"> <span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{pendingMe ? rows.length : (list.data?.total ?? 0)} {pendingMe ? totalRowCount : (list.data?.total ?? 0)}
</span> </span>
</div> </div>
</header> </header>
@ -191,65 +265,106 @@ export function PurchaseEvaluationsListPage() {
))} ))}
</div> </div>
)} )}
{!list.isLoading && rows.length === 0 && ( {!list.isLoading && projectGroups.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>
)} )}
<ul className="divide-y divide-slate-100"> {/* Plan AG Chunk B — Tree view 2-level Project > Gói thầu > PE (Outlook-style).
{rows.map(p => ( <details>/<summary> HTML native (no shadcn Accordion — gap component lib fe-user).
<li key={p.id}> Tailwind v3 named groups group/proj + group/gt cho chevron rotation animation.
<button [&::-webkit-details-marker]:hidden ẩn default disclosure triangle browser. */}
onClick={() => selectRow(p.id)} <div className="divide-y divide-slate-100">
className={cn( {projectGroups.map(pg => (
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50', <details
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', key={pg.projectId ?? '__no_project__'}
)} open={isExpanded(pg.projectId ?? '__no_project__')}
> onToggle={e => toggleExpand(pg.projectId ?? '__no_project__', (e.currentTarget as HTMLDetailsElement).open)}
<div className="flex items-start justify-between gap-2"> className="group/proj"
<div className="min-w-0 flex-1"> >
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</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 className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <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="font-mono">{p.maPhieu ?? '—'}</span> <span className="text-base">📁</span>
<span>·</span> <span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
<span className="truncate">{p.projectName}</span> <span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.totalCount}</span>
</div> </summary>
{p.selectedSupplierName && ( <div className="ml-3 border-l border-slate-200">
<div className="mt-0.5 truncate text-[11px] text-emerald-600"> {pg.goiThauList.map(gt => {
{p.selectedSupplierName} const gtKey = `${pg.projectId ?? '__no_project__'}::${gt.normalizedKey}`
</div> return (
)} <details
</div> key={gtKey}
<span open={isExpanded(gtKey)}
className={cn( onToggle={e => toggleExpand(gtKey, (e.currentTarget as HTMLDetailsElement).open)}
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', className="group/gt"
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">
> <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>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} <span className="text-sm">📄</span>
</span> <span className="flex-1 truncate text-[12px] text-slate-700">{gt.displayName}</span>
</div> <span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{gt.items.length}</span>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500"> </summary>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600"> <ul className="divide-y divide-slate-100">
{PurchaseEvaluationTypeLabel[p.type]} {gt.items.map(p => (
</span> <li key={p.id}>
{/* S23 t2 UAT: bro yêu cầu đổi SLA countdown → ngày giờ tạo phiếu. <button
BE list sort theo UpdatedAt DESC (fallback CreatedAt) — phiếu vừa onClick={() => selectRow(p.id)}
update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */} className={cn(
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}> 'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
{new Date(p.createdAt).toLocaleString('vi-VN', { selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
day: '2-digit', month: '2-digit', year: 'numeric', )}
hour: '2-digit', minute: '2-digit', >
})} <div className="flex items-start justify-between gap-2">
</span> <div className="min-w-0 flex-1">
</div> <div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
{p.contractId && ( <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div> <span className="font-mono">{p.maPhieu ?? '—'}</span>
)} <span>·</span>
</button> <span className="truncate">{p.projectName}</span>
</li> </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)],
)}
>
{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: bro yêu cầu đổi SLA countdown → ngày giờ tạo phiếu.
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>
</details>
))} ))}
</ul> </div>
</div> </div>
</aside> </aside>