[CLAUDE] FE: PE che do tap trung duyet phieu (overlay truot phai, an menu+list) + responsive laptop nho
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m40s

Bam phieu -> focus overlay (fixed inset-0 z-50) truot tu phai, che menu+TopBar+list rail; phieu full-width tu cuon + panel duyet ben canh (stack xuong duoi tren man nho). Thu gon/Esc/backdrop HOAC Duyet (chi forward-approve; Tra lai/Tu choi giu overlay) -> dong ve danh sach. List state = panel giua mx-auto max-w-5xl, giu cay 4 tang + PeUrgentChips + search/filter/SLA. Responsive lg-pivot (flex-col stack <lg, flex-row >=lg), compact laptop nho, KHONG dong cham man hinh to (anh: 'man to OK roi'). PeWorkflowPanel +onApproved (wasReject byte-mirror isReject). 2 app SHA256-identical. a11y role=dialog/Esc/focus-trap/scroll-lock/reduced-motion. FE-only, 0 BE, 0 migration. Build PASS x2. Note: backend down luc design -> live authed verify sau deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 13:28:26 +07:00
parent 8e68ed1892
commit 398b01d0a6
4 changed files with 402 additions and 90 deletions

View File

@ -27,10 +27,15 @@ import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
export function PeWorkflowPanel({
evaluation,
readOnly = false,
onApproved,
}: {
evaluation: PeDetailBundle
/** true = ẩn Chuyển tiếp + Dialog transition (dùng cho Danh sách, không dùng Duyệt). */
readOnly?: boolean
/** S78 — gọi sau khi DUYỆT (forward approve) thành công, để caller đóng focus
* overlay về danh sách (anh: "chọn duyệt thì nó đóng lại như ban đầu").
* KHÔNG gọi khi Trả lại / Từ chối (caller giữ overlay để xem kết quả). */
onApproved?: () => void
}) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
@ -158,6 +163,11 @@ export function PeWorkflowPanel({
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] })
// S78 — chỉ DUYỆT (forward approve) mới auto-đóng focus overlay. Trả lại /
// Từ chối giữ overlay (mirror isReject/isSendBack trong mutationFn).
const wasReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai)
setTarget(null)
setComment('')
setReturnMode(WorkflowReturnMode.Drafter)
@ -165,6 +175,7 @@ export function PeWorkflowPanel({
setSkipToFinalApprover(false)
setFinalizeByCcm(false)
setApprovedPriceSource(null)
if (!wasReject) onApproved?.()
},
onError: e => toast.error(getErrorMessage(e)),
})

View File

@ -3,11 +3,11 @@
// 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck, Search, X } from 'lucide-react'
import { ArrowLeft, ClipboardCheck, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { EmptyState } from '@/components/EmptyState'
@ -30,7 +30,6 @@ import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
export function PurchaseEvaluationsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
@ -95,21 +94,20 @@ export function PurchaseEvaluationsListPage() {
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const setParam = useCallback((key: string, value: string | null) => {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
if (key !== 'id') next.delete('page')
setSp(next, { replace: key === 'q' })
}
}, [sp, setSp])
function selectRow(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setParam('id', id)
} else {
navigate(`/purchase-evaluations/${id}`)
}
}
// S78 — focus mode: chọn phiếu → mở overlay full-bleed (che menu + list).
// KHÔNG còn route /purchase-evaluations/:id riêng cho mobile — overlay phục vụ
// CẢ desktop (phiếu rộng + panel duyệt cạnh) lẫn mobile (stack dọc). 1 code path.
const selectRow = useCallback((id: string) => setParam('id', id), [setParam])
// Đóng focus = clear ?id → quay lại danh sách. Duyệt xong cũng gọi closeFocus.
const closeFocus = useCallback(() => setParam('id', null), [setParam])
const allRows = list.data?.items ?? []
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
@ -210,6 +208,93 @@ export function PurchaseEvaluationsListPage() {
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: 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) ───────────
// Phiếu chính bị nhỏ trong panel giữa 1fr (~600px) → chọn phiếu mở overlay
// fixed inset-0 z-50 trượt từ phải, CHE hẳn menu trái + list rail. Phiếu full
// width (scroll riêng) + panel duyệt cạnh phải (desktop) / stack dưới (mobile).
// Mount-then-animate: `id` set → mount overlay → next frame bật `focusVisible`
// → translate-x-0 (trượt vào). Đóng → tắt visible → chờ transition → unmount.
const hasSelection = !!selectedId
const [focusMounted, setFocusMounted] = useState(false)
const [focusVisible, setFocusVisible] = useState(false)
const overlayRef = useRef<HTMLDivElement>(null)
const closeBtnRef = useRef<HTMLButtonElement>(null)
const prefersReducedMotion = useRef(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches,
)
useEffect(() => {
if (hasSelection) {
setFocusMounted(true)
// 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
// → bật ngay (CSS transition đã off).
if (prefersReducedMotion.current) {
setFocusVisible(true)
} else {
const r1 = requestAnimationFrame(() => {
const r2 = requestAnimationFrame(() => setFocusVisible(true))
;(window as unknown as { __peR2?: number }).__peR2 = r2
})
return () => {
cancelAnimationFrame(r1)
const r2 = (window as unknown as { __peR2?: number }).__peR2
if (r2) cancelAnimationFrame(r2)
}
}
} else {
setFocusVisible(false)
if (prefersReducedMotion.current) {
setFocusMounted(false)
} else {
const t = setTimeout(() => setFocusMounted(false), 240)
return () => clearTimeout(t)
}
}
}, [hasSelection])
// 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).
useEffect(() => {
if (!focusMounted) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const tf = setTimeout(() => closeBtnRef.current?.focus(), 60)
return () => {
document.body.style.overflow = prevOverflow
clearTimeout(tf)
}
}, [focusMounted])
// Esc đóng + focus-trap (Tab vòng trong overlay) — a11y dialog floor (FD5).
useEffect(() => {
if (!focusMounted) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
closeFocus()
return
}
if (e.key === 'Tab' && overlayRef.current) {
const focusables = overlayRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)
if (focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [focusMounted, closeFocus])
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
@ -222,10 +307,11 @@ export function PurchaseEvaluationsListPage() {
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1: List */}
<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">
{/* S78 — list state: 1 panel rộng thoải mái (panel giữa/phải đã chuyển vào
focus overlay). max-w để dòng phiếu không trải quá dài trên màn siêu rộng. */}
<div className="flex flex-1 flex-col overflow-hidden">
<aside className="mx-auto flex w-full max-w-5xl flex-1 flex-col overflow-hidden bg-white">
<div className="space-y-2 border-b border-slate-200 p-3 sm:p-4">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
@ -403,36 +489,95 @@ export function PurchaseEvaluationsListPage() {
</div>
</div>
</aside>
{/* Panel 2: Detail tabs */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!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.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={true}
/>
)}
</main>
{/* Panel 3: Workflow + history */}
{/* Danh sách (pendingMe=false) → readOnly=true → ẩn Chuyển tiếp transition.
Duyệt (pendingMe=true) → readOnly=false → cho approver chuyển phase. */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn phiếu.
</div>
)}
{selectedId && detail.data && <PeWorkflowPanel evaluation={detail.data} readOnly={!pendingMe} />}
</aside>
</div>
{/* ─── S78 Focus overlay (full-bleed, slide-from-right) ─────────────────
Che hẳn menu trái + TopBar + list. Phiếu full-width scroll RIÊNG (anh
Kiệt: "slibar của phiếu phải có") + panel duyệt cạnh phải (desktop) /
stack dưới (narrow). Duyệt xong → PeWorkflowPanel invalidate + đóng
(onApproved). Thu gọn / Esc / click backdrop mép trái → đóng về list. */}
{focusMounted && (
<div
className="fixed inset-0 z-50"
role="dialog"
aria-modal="true"
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
màn cực rộng vì panel max-w). */}
<div
className={cn(
'absolute inset-0 bg-slate-900/40 transition-opacity duration-200 motion-reduce:transition-none',
focusVisible ? 'opacity-100' : 'opacity-0',
)}
onClick={closeFocus}
aria-hidden="true"
/>
{/* Panel trượt — chiếm full viewport, transform translateX. */}
<div
ref={overlayRef}
className={cn(
'absolute inset-0 flex flex-col bg-slate-50 shadow-2xl transition-transform duration-200 ease-out will-change-transform motion-reduce:transition-none',
focusVisible ? 'translate-x-0' : 'translate-x-full',
)}
>
{/* Top strip riêng của overlay: Thu gọn + tên phiếu + trạng thái. */}
<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
ref={closeBtnRef}
onClick={closeFocus}
aria-label="Thu gọn — quay lại danh sách phiếu"
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" />
Thu gọn
</button>
<div className="flex min-w-0 flex-1 items-center gap-2">
<ClipboardCheck className="hidden h-4 w-4 shrink-0 text-brand-600 sm:block" />
<span className="truncate text-sm font-semibold text-slate-900">
{detail.data?.tenGoiThau ?? (detail.isLoading ? 'Đang tải…' : 'Chi tiết phiếu')}
</span>
{pendingMe && (
<span className="hidden shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700 sm:inline">
Chế đ duyệt
</span>
)}
</div>
</div>
{/* Body: phiếu (scroll riêng) | panel duyệt (scroll riêng).
Desktop = 2 cột [1fr | 360-400px]. Narrow (<lg) = 1 cột stack:
phiếu trên, panel duyệt dưới — 1 code path phục vụ cả 2. */}
{detail.isLoading && (
<div className="flex flex-1 items-center justify-center text-sm text-slate-500">Đang tải phiếu</div>
)}
{!detail.isLoading && !detail.data && (
<div className="flex flex-1 items-center justify-center p-6 text-sm text-red-600">Không tìm thấy phiếu.</div>
)}
{detail.data && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto lg:flex-row lg:overflow-hidden">
{/* Cột phiếu — scrollbar ĐỘC LẬP (anh Kiệt yêu cầu). */}
<main className="min-w-0 flex-1 p-3 sm:p-5 lg:overflow-y-auto lg:p-6">
<PeDetailTabs
evaluation={detail.data}
onBack={closeFocus}
onDelete={() => del.mutate(detail.data!.id)}
readOnly={true}
/>
</main>
{/* Panel duyệt — cạnh phải desktop, dưới phiếu narrow. Scroll riêng. */}
<aside className="shrink-0 border-t border-slate-200 bg-white p-3 sm:p-5 lg:w-[24rem] lg:border-l lg:border-t-0 lg:p-5 lg:overflow-y-auto xl:w-[26rem]">
<PeWorkflowPanel
evaluation={detail.data}
readOnly={!pendingMe}
onApproved={closeFocus}
/>
</aside>
</div>
)}
</div>
</div>
)}
</div>
)
}