[CLAUDE] FE: PE tach chon-phieu (inline 3-panel nhu cu) khoi mo-rong (overlay) + nut Xem mo rong moi dong
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m47s

anh (annotate 2026-06-19): bam dong phieu = xem thuong inline 3-panel NHU CU; them nut 'Xem mo rong' (Maximize2) goc moi dong -> overlay full-man. Param ?expand=1 tach khoi ?id: click row=select (wide: inline 3-panel / narrow <lg: overlay vi 3-panel khong vua man); 'Xem mo rong'=id+expand; Thu gon/Esc/backdrop=bo expand GIU id (ve inline); Dong/duyet/xoa=clear ca 2 (ve list). Overlay render khi id&&expand; inline detail khi id&&!isExpand. em-main review them !isExpand guard vao 2 panel inline -> tranh double-mount PeDetailTabs/PeWorkflowPanel phia sau overlay (intent comment co nhung code sot). Giu tree 4 tang + PeUrgentChips + overlay a11y (slide/Esc/focus-trap). FE-only, 2 app SHA256-identical. Build PASS x2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 14:57:10 +07:00
parent b5aa72d005
commit fa6654b8f4
2 changed files with 336 additions and 172 deletions

View File

@ -1,5 +1,5 @@
// 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), expand (1=overlay).
// Plan AG Phase 1 (S26 2026-05-21) — Tree view 2-level Project > Gói thầu > PE // 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. // 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. // FE-only group view (no schema change) — Phase 2 ProjectPackage defer sau UAT confirm.
@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, 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'
import { ArrowLeft, ClipboardCheck, Search, X } from 'lucide-react' import { ArrowLeft, ClipboardCheck, Maximize2, Search, X } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
@ -38,6 +38,7 @@ export function PurchaseEvaluationsListPage() {
const phase = sp.get('phase') ?? '' const phase = sp.get('phase') ?? ''
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
const selectedId = sp.get('id') const selectedId = sp.get('id')
const isExpand = sp.get('expand') === '1' // S79 — overlay mở rộng
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen) // Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
const approvalWorkflows = useQuery({ const approvalWorkflows = useQuery({
@ -88,7 +89,12 @@ export function PurchaseEvaluationsListPage() {
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`), mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => { onSuccess: () => {
toast.success('Đã xóa phiếu.') toast.success('Đã xóa phiếu.')
setParam('id', null) // Xoá phiếu → clear cả id + expand (về danh sách).
const next = new URLSearchParams(sp)
next.delete('id')
next.delete('expand')
next.delete('page')
setSp(next)
qc.invalidateQueries({ queryKey: ['pe-list'] }) qc.invalidateQueries({ queryKey: ['pe-list'] })
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -102,12 +108,47 @@ export function PurchaseEvaluationsListPage() {
setSp(next, { replace: key === 'q' }) setSp(next, { replace: key === 'q' })
}, [sp, setSp]) }, [sp, setSp])
// S78focus mode: chọn phiếu → mở overlay full-bleed (che menu + list). // S79decouple "chọn" khỏi "mở rộng" (anh chốt 2026-06-19, annotated screenshot):
// KHÔNG còn route /purchase-evaluations/:id riêng cho mobile — overlay phục vụ // • Bấm dòng phiếu = chọn → detail INLINE 3-panel "như cũ" (8e68ed1). Trên màn
// CẢ desktop (phiếu rộng + panel duyệt cạnh) lẫn mobile (stack dọc). 1 code path. // hẹp (<lg) 3-panel không vừa → bấm dòng đi thẳng overlay full-bleed.
const selectRow = useCallback((id: string) => setParam('id', id), [setParam]) // • Nút "Xem mở rộng" mỗi dòng = id + expand=1 → overlay trượt full màn.
// Đóng focus = clear ?id → quay lại danh sách. Duyệt xong cũng gọi closeFocus. // • Overlay render CHỈ khi id && expand. Inline detail render khi id && !expand.
const closeFocus = useCallback(() => setParam('id', null), [setParam]) const LG_BREAKPOINT = 1024
const isWideViewport = () => typeof window !== 'undefined' && window.innerWidth >= LG_BREAKPOINT
// Chọn dòng: set id; clear expand. <lg → set expand=1 luôn (đi thẳng overlay vì
// 3-panel inline không vừa màn hẹp).
const selectRow = useCallback((id: string) => {
const next = new URLSearchParams(sp)
next.set('id', id)
if (isWideViewport()) next.delete('expand')
else next.set('expand', '1')
setSp(next)
}, [sp, setSp])
// "Xem mở rộng" trên 1 dòng → mở overlay cho phiếu đó (id + expand=1).
const expandRow = useCallback((id: string) => {
const next = new URLSearchParams(sp)
next.set('id', id)
next.set('expand', '1')
setSp(next)
}, [sp, setSp])
// Thu gọn overlay → bỏ expand, GIỮ id (quay lại inline detail, KHÔNG về list).
const collapseFocus = useCallback(() => setParam('expand', null), [setParam])
// Đóng inline detail (nút ← Đóng / xoá phiếu) → clear id + expand → về danh sách.
const closeDetail = useCallback(() => {
const next = new URLSearchParams(sp)
next.delete('id')
next.delete('expand')
next.delete('page')
setSp(next)
}, [sp, setSp])
// Duyệt xong (onApproved, không phải Trả lại/Từ chối) → phiếu rời inbox →
// đóng hẳn overlay + clear selection (về danh sách).
const onApproved = useCallback(() => closeDetail(), [closeDetail])
const allRows = list.data?.items ?? [] const allRows = list.data?.items ?? []
// 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/
@ -208,13 +249,11 @@ export function PurchaseEvaluationsListPage() {
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter]) ? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC' : pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
// ─── S78 Focus-mode overlay (anh Kiệt FDC + anh chốt 2026-06-18) ─────────── // ─── S79 Focus-mode overlay (anh chốt 2026-06-19) ─────────────────────────
// Phiếu chính bị nhỏ trong panel giữa 1fr (~600px) → chọn phiếu mở overlay // Overlay full-bleed trượt từ phải CHE menu trái + TopBar + list + inline
// fixed inset-0 z-50 trượt từ phải, CHE hẳn menu trái + list rail. Phiếu full // detail. Chỉ mở khi `expand=1`. Thu gọn → bỏ expand (về inline detail).
// width (scroll riêng) + panel duyệt cạnh phải (desktop) / stack dưới (mobile). // Mount-then-animate: expand bật → mount overlay → next frame → translate-x-0.
// Mount-then-animate: `id` set → mount overlay → next frame bật `focusVisible` const overlayActive = !!selectedId && isExpand
// → translate-x-0 (trượt vào). Đóng → tắt visible → chờ transition → unmount.
const hasSelection = !!selectedId
const [focusMounted, setFocusMounted] = useState(false) const [focusMounted, setFocusMounted] = useState(false)
const [focusVisible, setFocusVisible] = useState(false) const [focusVisible, setFocusVisible] = useState(false)
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
@ -224,7 +263,7 @@ export function PurchaseEvaluationsListPage() {
) )
useEffect(() => { useEffect(() => {
if (hasSelection) { if (overlayActive) {
setFocusMounted(true) setFocusMounted(true)
// 2 rAF để browser commit translate-x-full TRƯỚC khi đổi → translate-x-0 // 2 rAF để browser commit translate-x-full TRƯỚC khi đổi → translate-x-0
// (1 rAF đôi khi bị batch chung frame → không thấy slide). reduced-motion // (1 rAF đôi khi bị batch chung frame → không thấy slide). reduced-motion
@ -251,7 +290,7 @@ export function PurchaseEvaluationsListPage() {
return () => clearTimeout(t) return () => clearTimeout(t)
} }
} }
}, [hasSelection]) }, [overlayActive])
// Khoá scroll body khi overlay mở (tránh double-scroll nền). Focus nút Thu gọn // Khoá scroll body khi overlay mở (tránh double-scroll nền). Focus nút Thu gọn
// khi mở (a11y — đưa focus vào overlay). // khi mở (a11y — đưa focus vào overlay).
@ -266,13 +305,13 @@ export function PurchaseEvaluationsListPage() {
} }
}, [focusMounted]) }, [focusMounted])
// Esc đóng + focus-trap (Tab vòng trong overlay) — a11y dialog floor (FD5). // Esc thu gọn + focus-trap (Tab vòng trong overlay) — a11y dialog floor (FD5).
useEffect(() => { useEffect(() => {
if (!focusMounted) return if (!focusMounted) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
closeFocus() collapseFocus()
return return
} }
if (e.key === 'Tab' && overlayRef.current) { if (e.key === 'Tab' && overlayRef.current) {
@ -293,7 +332,7 @@ export function PurchaseEvaluationsListPage() {
} }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey)
}, [focusMounted, closeFocus]) }, [focusMounted, collapseFocus])
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col"> <div className="flex h-[calc(100vh-4rem)] flex-col">
@ -307,10 +346,11 @@ export function PurchaseEvaluationsListPage() {
</div> </div>
</header> </header>
{/* S79 — list state RESTORE bản gốc 8e68ed1: grid 3-panel bám trái lấp đầy {/* S79 — list state: grid 3-panel bám trái lấp đầy màn hình. List <aside>
màn hình (KHÔNG canh giữa). List <aside> trái + 2 placeholder panel trái + Detail tabs (giữa) + Workflow panel (phải) INLINE "như cũ"
giữa/phải. Chọn phiếu (?id) → focus overlay S78 CHE hẳn tất cả → 2 (8e68ed1). Bấm dòng → chọn → 2 panel phải render detail. Nút "Xem mở
placeholder ở yên (overlay phủ lên). <lg → grid về 1 cột = chỉ list. */} rộng" mỗi dòng → overlay full-bleed CHE hẳn. <lg → grid 1 cột (chỉ
list) → bấm dòng đi thẳng overlay. */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]"> <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1: List — bám trái, fills, không max-w/mx-auto. */} {/* Panel 1: List — bám trái, fills, không max-w/mx-auto. */}
<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">
@ -430,51 +470,71 @@ export function PurchaseEvaluationsListPage() {
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200"> <ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
{wg.items.map(p => ( {wg.items.map(p => (
<li key={p.id}> <li key={p.id}>
<button {/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút
onClick={() => selectRow(p.id)} "Xem mở rộng" (overlay) nằm trong cùng row, group hover. */}
<div
className={cn( className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-slate-50', 'group/row relative flex items-stretch transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)} )}
> >
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */} <button
<div className="flex items-start justify-between gap-2"> onClick={() => selectRow(p.id)}
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900"> className="block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60"
{/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */} >
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} /> {/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
<span className="truncate">{p.tenGoiThau}</span> <div className="flex items-start justify-between gap-2">
</div> <div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
<span {/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */}
className={cn( <PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', <span className="truncate">{p.tenGoiThau}</span>
PeDisplayStatusColor[getPeDisplayStatus(p.phase)], </div>
)} <span
> className={cn(
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} 'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
</span> PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
</div> )}
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> >
<span className="font-mono">{p.maPhieu ?? '—'}</span> {PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
<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')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</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> </span>
{p.contractId && <span className="shrink-0 text-[10px] font-medium text-brand-600"> </span>}
</div> </div>
)} <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
</button> <span className="font-mono">{p.maPhieu ?? '—'}</span>
<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')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</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>
{/* "Xem mở rộng" — icon-button góc phải, lộ rõ khi hover/focus
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip). */}
<button
type="button"
onClick={() => expandRow(p.id)}
title="Xem mở rộng"
aria-label={`Xem mở rộng phiếu ${p.tenGoiThau}`}
className={cn(
'absolute right-1.5 top-1.5 inline-flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 opacity-0 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 group-hover/row:opacity-100',
selectedId === p.id && 'opacity-100',
)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
@ -493,27 +553,50 @@ export function PurchaseEvaluationsListPage() {
</div> </div>
</aside> </aside>
{/* Panel 2: placeholder giữa (như bản gốc). Chọn phiếu → focus overlay {/* Panel 2: Detail tabs INLINE (như 8e68ed1). Render khi chọn phiếu &&
S78 phủ lên trên, placeholder này KHÔNG đổi (không render detail inline chưa mở rộng. Khi expand → overlay phủ lên (panel này vẫn ở dưới). */}
phía sau overlay → tránh double-mount PeDetailTabs). */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." /> {!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !detail.data && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)}
{selectedId && !isExpand && detail.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={closeDetail}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={true}
/>
)}
</main> </main>
{/* Panel 3: placeholder phải (như bản gốc). */} {/* Panel 3: Workflow panel INLINE (như 8e68ed1). */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block"> <aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400"> {!selectedId && (
<X className="mx-auto mb-2 h-5 w-5" /> <div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
Quy trình duyệt sẽ hiện khi chọn phiếu. <X className="mx-auto mb-2 h-5 w-5" />
</div> Quy trình duyệt sẽ hiện khi chọn phiếu.
</div>
)}
{selectedId && !isExpand && detail.data && (
<PeWorkflowPanel
evaluation={detail.data}
readOnly={!pendingMe}
onApproved={onApproved}
/>
)}
</aside> </aside>
</div> </div>
{/* ─── S78 Focus overlay (full-bleed, slide-from-right) ───────────────── {/* ─── S79 Focus overlay (full-bleed, slide-from-right) ─────────────────
Che hẳn menu trái + TopBar + list. Phiếu full-width scroll RIÊNG (anh Render CHỈ khi id && expand. Che hẳn menu trái + TopBar + list + inline
Kiệt: "slibar của phiếu phải có") + panel duyệt cạnh phải (desktop) / detail. Phiếu full-width scroll RIÊNG (anh Kiệt: "slibar của phiếu phải
stack dưới (narrow). Duyệt xong → PeWorkflowPanel invalidate + đóng có") + panel duyệt cạnh phải (desktop) / stack dưới (narrow). Duyệt
(onApproved). Thu gọn / Esc / click backdrop mép trái → đóng về list. */} xong → onApproved đóng hẳn (về list). Thu gọn / Esc / click backdrop
bỏ expand (về inline detail, GIỮ id). */}
{focusMounted && ( {focusMounted && (
<div <div
className="fixed inset-0 z-50" className="fixed inset-0 z-50"
@ -521,14 +604,14 @@ export function PurchaseEvaluationsListPage() {
aria-modal="true" aria-modal="true"
aria-label={`Chi tiết phiếu ${detail.data?.tenGoiThau ?? ''}`} aria-label={`Chi tiết phiếu ${detail.data?.tenGoiThau ?? ''}`}
> >
{/* Backdrop mờ — fade riêng, click để đóng (mép trái còn hở ~chút trên {/* Backdrop mờ — fade riêng, click để thu gọn (mép trái còn hở ~chút
màn cực rộng vì panel max-w). */} trên màn cực rộng vì panel max-w). */}
<div <div
className={cn( className={cn(
'absolute inset-0 bg-slate-900/40 transition-opacity duration-200 motion-reduce:transition-none', 'absolute inset-0 bg-slate-900/40 transition-opacity duration-200 motion-reduce:transition-none',
focusVisible ? 'opacity-100' : 'opacity-0', focusVisible ? 'opacity-100' : 'opacity-0',
)} )}
onClick={closeFocus} onClick={collapseFocus}
aria-hidden="true" aria-hidden="true"
/> />
{/* Panel trượt — chiếm full viewport, transform translateX. */} {/* Panel trượt — chiếm full viewport, transform translateX. */}
@ -543,8 +626,8 @@ export function PurchaseEvaluationsListPage() {
<div className="flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5 sm:px-6"> <div className="flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5 sm:px-6">
<button <button
ref={closeBtnRef} ref={closeBtnRef}
onClick={closeFocus} onClick={collapseFocus}
aria-label="Thu gọn — quay lại danh sách phiếu" aria-label="Thu gọn — quay lại chi tiết trong danh sách"
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 focus-visible:ring-offset-1" className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 focus-visible:ring-offset-1"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -578,7 +661,7 @@ export function PurchaseEvaluationsListPage() {
<main className="min-w-0 flex-1 p-3 sm:p-5 lg:overflow-y-auto lg:p-6"> <main className="min-w-0 flex-1 p-3 sm:p-5 lg:overflow-y-auto lg:p-6">
<PeDetailTabs <PeDetailTabs
evaluation={detail.data} evaluation={detail.data}
onBack={closeFocus} onBack={collapseFocus}
onDelete={() => del.mutate(detail.data!.id)} onDelete={() => del.mutate(detail.data!.id)}
readOnly={true} readOnly={true}
/> />
@ -588,7 +671,7 @@ export function PurchaseEvaluationsListPage() {
<PeWorkflowPanel <PeWorkflowPanel
evaluation={detail.data} evaluation={detail.data}
readOnly={!pendingMe} readOnly={!pendingMe}
onApproved={closeFocus} onApproved={onApproved}
/> />
</aside> </aside>
</div> </div>
@ -624,4 +707,3 @@ export function PurchaseEvaluationDetailPage() {
</div> </div>
) )
} }

View File

@ -1,5 +1,5 @@
// 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), expand (1=overlay).
// Plan AG Phase 1 (S26 2026-05-21) — Tree view 2-level Project > Gói thầu > PE // 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. // 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. // FE-only group view (no schema change) — Phase 2 ProjectPackage defer sau UAT confirm.
@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, 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'
import { ArrowLeft, ClipboardCheck, Search, X } from 'lucide-react' import { ArrowLeft, ClipboardCheck, Maximize2, Search, X } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
@ -38,6 +38,7 @@ export function PurchaseEvaluationsListPage() {
const phase = sp.get('phase') ?? '' const phase = sp.get('phase') ?? ''
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
const selectedId = sp.get('id') const selectedId = sp.get('id')
const isExpand = sp.get('expand') === '1' // S79 — overlay mở rộng
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen) // Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
const approvalWorkflows = useQuery({ const approvalWorkflows = useQuery({
@ -88,7 +89,12 @@ export function PurchaseEvaluationsListPage() {
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`), mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => { onSuccess: () => {
toast.success('Đã xóa phiếu.') toast.success('Đã xóa phiếu.')
setParam('id', null) // Xoá phiếu → clear cả id + expand (về danh sách).
const next = new URLSearchParams(sp)
next.delete('id')
next.delete('expand')
next.delete('page')
setSp(next)
qc.invalidateQueries({ queryKey: ['pe-list'] }) qc.invalidateQueries({ queryKey: ['pe-list'] })
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -102,12 +108,47 @@ export function PurchaseEvaluationsListPage() {
setSp(next, { replace: key === 'q' }) setSp(next, { replace: key === 'q' })
}, [sp, setSp]) }, [sp, setSp])
// S78focus mode: chọn phiếu → mở overlay full-bleed (che menu + list). // S79decouple "chọn" khỏi "mở rộng" (anh chốt 2026-06-19, annotated screenshot):
// KHÔNG còn route /purchase-evaluations/:id riêng cho mobile — overlay phục vụ // • Bấm dòng phiếu = chọn → detail INLINE 3-panel "như cũ" (8e68ed1). Trên màn
// CẢ desktop (phiếu rộng + panel duyệt cạnh) lẫn mobile (stack dọc). 1 code path. // hẹp (<lg) 3-panel không vừa → bấm dòng đi thẳng overlay full-bleed.
const selectRow = useCallback((id: string) => setParam('id', id), [setParam]) // • Nút "Xem mở rộng" mỗi dòng = id + expand=1 → overlay trượt full màn.
// Đóng focus = clear ?id → quay lại danh sách. Duyệt xong cũng gọi closeFocus. // • Overlay render CHỈ khi id && expand. Inline detail render khi id && !expand.
const closeFocus = useCallback(() => setParam('id', null), [setParam]) const LG_BREAKPOINT = 1024
const isWideViewport = () => typeof window !== 'undefined' && window.innerWidth >= LG_BREAKPOINT
// Chọn dòng: set id; clear expand. <lg → set expand=1 luôn (đi thẳng overlay vì
// 3-panel inline không vừa màn hẹp).
const selectRow = useCallback((id: string) => {
const next = new URLSearchParams(sp)
next.set('id', id)
if (isWideViewport()) next.delete('expand')
else next.set('expand', '1')
setSp(next)
}, [sp, setSp])
// "Xem mở rộng" trên 1 dòng → mở overlay cho phiếu đó (id + expand=1).
const expandRow = useCallback((id: string) => {
const next = new URLSearchParams(sp)
next.set('id', id)
next.set('expand', '1')
setSp(next)
}, [sp, setSp])
// Thu gọn overlay → bỏ expand, GIỮ id (quay lại inline detail, KHÔNG về list).
const collapseFocus = useCallback(() => setParam('expand', null), [setParam])
// Đóng inline detail (nút ← Đóng / xoá phiếu) → clear id + expand → về danh sách.
const closeDetail = useCallback(() => {
const next = new URLSearchParams(sp)
next.delete('id')
next.delete('expand')
next.delete('page')
setSp(next)
}, [sp, setSp])
// Duyệt xong (onApproved, không phải Trả lại/Từ chối) → phiếu rời inbox →
// đóng hẳn overlay + clear selection (về danh sách).
const onApproved = useCallback(() => closeDetail(), [closeDetail])
const allRows = list.data?.items ?? [] const allRows = list.data?.items ?? []
// 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/
@ -208,13 +249,11 @@ export function PurchaseEvaluationsListPage() {
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter]) ? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC' : pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
// ─── S78 Focus-mode overlay (anh Kiệt FDC + anh chốt 2026-06-18) ─────────── // ─── S79 Focus-mode overlay (anh chốt 2026-06-19) ─────────────────────────
// Phiếu chính bị nhỏ trong panel giữa 1fr (~600px) → chọn phiếu mở overlay // Overlay full-bleed trượt từ phải CHE menu trái + TopBar + list + inline
// fixed inset-0 z-50 trượt từ phải, CHE hẳn menu trái + list rail. Phiếu full // detail. Chỉ mở khi `expand=1`. Thu gọn → bỏ expand (về inline detail).
// width (scroll riêng) + panel duyệt cạnh phải (desktop) / stack dưới (mobile). // Mount-then-animate: expand bật → mount overlay → next frame → translate-x-0.
// Mount-then-animate: `id` set → mount overlay → next frame bật `focusVisible` const overlayActive = !!selectedId && isExpand
// → translate-x-0 (trượt vào). Đóng → tắt visible → chờ transition → unmount.
const hasSelection = !!selectedId
const [focusMounted, setFocusMounted] = useState(false) const [focusMounted, setFocusMounted] = useState(false)
const [focusVisible, setFocusVisible] = useState(false) const [focusVisible, setFocusVisible] = useState(false)
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
@ -224,7 +263,7 @@ export function PurchaseEvaluationsListPage() {
) )
useEffect(() => { useEffect(() => {
if (hasSelection) { if (overlayActive) {
setFocusMounted(true) setFocusMounted(true)
// 2 rAF để browser commit translate-x-full TRƯỚC khi đổi → translate-x-0 // 2 rAF để browser commit translate-x-full TRƯỚC khi đổi → translate-x-0
// (1 rAF đôi khi bị batch chung frame → không thấy slide). reduced-motion // (1 rAF đôi khi bị batch chung frame → không thấy slide). reduced-motion
@ -251,7 +290,7 @@ export function PurchaseEvaluationsListPage() {
return () => clearTimeout(t) return () => clearTimeout(t)
} }
} }
}, [hasSelection]) }, [overlayActive])
// Khoá scroll body khi overlay mở (tránh double-scroll nền). Focus nút Thu gọn // Khoá scroll body khi overlay mở (tránh double-scroll nền). Focus nút Thu gọn
// khi mở (a11y — đưa focus vào overlay). // khi mở (a11y — đưa focus vào overlay).
@ -266,13 +305,13 @@ export function PurchaseEvaluationsListPage() {
} }
}, [focusMounted]) }, [focusMounted])
// Esc đóng + focus-trap (Tab vòng trong overlay) — a11y dialog floor (FD5). // Esc thu gọn + focus-trap (Tab vòng trong overlay) — a11y dialog floor (FD5).
useEffect(() => { useEffect(() => {
if (!focusMounted) return if (!focusMounted) return
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
closeFocus() collapseFocus()
return return
} }
if (e.key === 'Tab' && overlayRef.current) { if (e.key === 'Tab' && overlayRef.current) {
@ -293,7 +332,7 @@ export function PurchaseEvaluationsListPage() {
} }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey)
}, [focusMounted, closeFocus]) }, [focusMounted, collapseFocus])
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col"> <div className="flex h-[calc(100vh-4rem)] flex-col">
@ -307,10 +346,11 @@ export function PurchaseEvaluationsListPage() {
</div> </div>
</header> </header>
{/* S79 — list state RESTORE bản gốc 8e68ed1: grid 3-panel bám trái lấp đầy {/* S79 — list state: grid 3-panel bám trái lấp đầy màn hình. List <aside>
màn hình (KHÔNG canh giữa). List <aside> trái + 2 placeholder panel trái + Detail tabs (giữa) + Workflow panel (phải) INLINE "như cũ"
giữa/phải. Chọn phiếu (?id) → focus overlay S78 CHE hẳn tất cả → 2 (8e68ed1). Bấm dòng → chọn → 2 panel phải render detail. Nút "Xem mở
placeholder ở yên (overlay phủ lên). <lg → grid về 1 cột = chỉ list. */} rộng" mỗi dòng → overlay full-bleed CHE hẳn. <lg → grid 1 cột (chỉ
list) → bấm dòng đi thẳng overlay. */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]"> <div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1: List — bám trái, fills, không max-w/mx-auto. */} {/* Panel 1: List — bám trái, fills, không max-w/mx-auto. */}
<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">
@ -430,51 +470,71 @@ export function PurchaseEvaluationsListPage() {
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200"> <ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
{wg.items.map(p => ( {wg.items.map(p => (
<li key={p.id}> <li key={p.id}>
<button {/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút
onClick={() => selectRow(p.id)} "Xem mở rộng" (overlay) nằm trong cùng row, group hover. */}
<div
className={cn( className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-slate-50', 'group/row relative flex items-stretch transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200', selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)} )}
> >
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */} <button
<div className="flex items-start justify-between gap-2"> onClick={() => selectRow(p.id)}
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900"> className="block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60"
{/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */} >
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} /> {/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
<span className="truncate">{p.tenGoiThau}</span> <div className="flex items-start justify-between gap-2">
</div> <div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
<span {/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */}
className={cn( <PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', <span className="truncate">{p.tenGoiThau}</span>
PeDisplayStatusColor[getPeDisplayStatus(p.phase)], </div>
)} <span
> className={cn(
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} 'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
</span> PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
</div> )}
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> >
<span className="font-mono">{p.maPhieu ?? '—'}</span> {PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
<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')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</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> </span>
{p.contractId && <span className="shrink-0 text-[10px] font-medium text-brand-600"> </span>}
</div> </div>
)} <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
</button> <span className="font-mono">{p.maPhieu ?? '—'}</span>
<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')}`}>
{new Date(p.createdAt).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</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>
{/* "Xem mở rộng" — icon-button góc phải, lộ rõ khi hover/focus
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip). */}
<button
type="button"
onClick={() => expandRow(p.id)}
title="Xem mở rộng"
aria-label={`Xem mở rộng phiếu ${p.tenGoiThau}`}
className={cn(
'absolute right-1.5 top-1.5 inline-flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 opacity-0 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 group-hover/row:opacity-100',
selectedId === p.id && 'opacity-100',
)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
@ -493,27 +553,50 @@ export function PurchaseEvaluationsListPage() {
</div> </div>
</aside> </aside>
{/* Panel 2: placeholder giữa (như bản gốc). Chọn phiếu → focus overlay {/* Panel 2: Detail tabs INLINE (như 8e68ed1). Render khi chọn phiếu &&
S78 phủ lên trên, placeholder này KHÔNG đổi (không render detail inline chưa mở rộng. Khi expand → overlay phủ lên (panel này vẫn ở dưới). */}
phía sau overlay → tránh double-mount PeDetailTabs). */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block"> <main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." /> {!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && !detail.isLoading && !detail.data && (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)}
{selectedId && !isExpand && detail.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={closeDetail}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={true}
/>
)}
</main> </main>
{/* Panel 3: placeholder phải (như bản gốc). */} {/* Panel 3: Workflow panel INLINE (như 8e68ed1). */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block"> <aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400"> {!selectedId && (
<X className="mx-auto mb-2 h-5 w-5" /> <div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
Quy trình duyệt sẽ hiện khi chọn phiếu. <X className="mx-auto mb-2 h-5 w-5" />
</div> Quy trình duyệt sẽ hiện khi chọn phiếu.
</div>
)}
{selectedId && !isExpand && detail.data && (
<PeWorkflowPanel
evaluation={detail.data}
readOnly={!pendingMe}
onApproved={onApproved}
/>
)}
</aside> </aside>
</div> </div>
{/* ─── S78 Focus overlay (full-bleed, slide-from-right) ───────────────── {/* ─── S79 Focus overlay (full-bleed, slide-from-right) ─────────────────
Che hẳn menu trái + TopBar + list. Phiếu full-width scroll RIÊNG (anh Render CHỈ khi id && expand. Che hẳn menu trái + TopBar + list + inline
Kiệt: "slibar của phiếu phải có") + panel duyệt cạnh phải (desktop) / detail. Phiếu full-width scroll RIÊNG (anh Kiệt: "slibar của phiếu phải
stack dưới (narrow). Duyệt xong → PeWorkflowPanel invalidate + đóng có") + panel duyệt cạnh phải (desktop) / stack dưới (narrow). Duyệt
(onApproved). Thu gọn / Esc / click backdrop mép trái → đóng về list. */} xong → onApproved đóng hẳn (về list). Thu gọn / Esc / click backdrop
bỏ expand (về inline detail, GIỮ id). */}
{focusMounted && ( {focusMounted && (
<div <div
className="fixed inset-0 z-50" className="fixed inset-0 z-50"
@ -521,14 +604,14 @@ export function PurchaseEvaluationsListPage() {
aria-modal="true" aria-modal="true"
aria-label={`Chi tiết phiếu ${detail.data?.tenGoiThau ?? ''}`} aria-label={`Chi tiết phiếu ${detail.data?.tenGoiThau ?? ''}`}
> >
{/* Backdrop mờ — fade riêng, click để đóng (mép trái còn hở ~chút trên {/* Backdrop mờ — fade riêng, click để thu gọn (mép trái còn hở ~chút
màn cực rộng vì panel max-w). */} trên màn cực rộng vì panel max-w). */}
<div <div
className={cn( className={cn(
'absolute inset-0 bg-slate-900/40 transition-opacity duration-200 motion-reduce:transition-none', 'absolute inset-0 bg-slate-900/40 transition-opacity duration-200 motion-reduce:transition-none',
focusVisible ? 'opacity-100' : 'opacity-0', focusVisible ? 'opacity-100' : 'opacity-0',
)} )}
onClick={closeFocus} onClick={collapseFocus}
aria-hidden="true" aria-hidden="true"
/> />
{/* Panel trượt — chiếm full viewport, transform translateX. */} {/* Panel trượt — chiếm full viewport, transform translateX. */}
@ -543,8 +626,8 @@ export function PurchaseEvaluationsListPage() {
<div className="flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5 sm:px-6"> <div className="flex shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5 sm:px-6">
<button <button
ref={closeBtnRef} ref={closeBtnRef}
onClick={closeFocus} onClick={collapseFocus}
aria-label="Thu gọn — quay lại danh sách phiếu" aria-label="Thu gọn — quay lại chi tiết trong danh sách"
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 focus-visible:ring-offset-1" className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 focus-visible:ring-offset-1"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -578,7 +661,7 @@ export function PurchaseEvaluationsListPage() {
<main className="min-w-0 flex-1 p-3 sm:p-5 lg:overflow-y-auto lg:p-6"> <main className="min-w-0 flex-1 p-3 sm:p-5 lg:overflow-y-auto lg:p-6">
<PeDetailTabs <PeDetailTabs
evaluation={detail.data} evaluation={detail.data}
onBack={closeFocus} onBack={collapseFocus}
onDelete={() => del.mutate(detail.data!.id)} onDelete={() => del.mutate(detail.data!.id)}
readOnly={true} readOnly={true}
/> />
@ -588,7 +671,7 @@ export function PurchaseEvaluationsListPage() {
<PeWorkflowPanel <PeWorkflowPanel
evaluation={detail.data} evaluation={detail.data}
readOnly={!pendingMe} readOnly={!pendingMe}
onApproved={closeFocus} onApproved={onApproved}
/> />
</aside> </aside>
</div> </div>
@ -624,4 +707,3 @@ export function PurchaseEvaluationDetailPage() {
</div> </div>
) )
} }