From 398b01d0a64a34dfc2037a7f957f58c2eb7909d7 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 19 Jun 2026 13:28:26 +0700 Subject: [PATCH] [CLAUDE] FE: PE che do tap trung duyet phieu (overlay truot phai, an menu+list) + responsive laptop nho 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), 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 --- .../src/components/pe/PeWorkflowPanel.tsx | 11 + .../pages/pe/PurchaseEvaluationsListPage.tsx | 235 ++++++++++++++---- fe-user/src/components/pe/PeWorkflowPanel.tsx | 11 + .../pages/pe/PurchaseEvaluationsListPage.tsx | 235 ++++++++++++++---- 4 files changed, 402 insertions(+), 90 deletions(-) diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index 5f365f9..86c56e0 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -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(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)), }) diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx index c11a922..401ecaf 100644 --- a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -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(null) + const closeBtnRef = useRef(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( + '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 (
@@ -222,10 +307,11 @@ export function PurchaseEvaluationsListPage() {
-
- {/* Panel 1: List */} -