// 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), expand (1=overlay). // 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 { 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 { ArrowLeft, ClipboardCheck, Maximize2, Search, X } from 'lucide-react' import { Input } from '@/components/ui/Input' import { Select } from '@/components/ui/Select' import { EmptyState } from '@/components/EmptyState' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import type { Paged } from '@/types/master' import { PeDisplayStatus, PeDisplayStatusColor, PeDisplayStatusLabel, PurchaseEvaluationPhase, PurchaseEvaluationTypeLabel, getPeDisplayStatus, type PeDetailBundle, type PeListItem, } from '@/types/purchaseEvaluation' import { PeDetailTabs } from '@/components/pe/PeDetailTabs' import { PeUrgentChips } from '@/components/pe/PeUrgentChips' import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel' export function PurchaseEvaluationsListPage() { const qc = useQueryClient() const [sp, setSp] = useSearchParams() const typeFilter = sp.get('type') ? Number(sp.get('type')) : null const pendingMe = sp.get('pendingMe') === '1' const search = sp.get('q') ?? '' const phase = sp.get('phase') ?? '' const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình 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) const approvalWorkflows = useQuery({ queryKey: ['approval-workflows-v2-filter', typeFilter], queryFn: async () => { if (!typeFilter) return [] const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean }[] }[] }>( '/approval-workflows-v2', { params: { applicableType: typeFilter } }, ) return res.data.types.find(t => t.applicableType === typeFilter)?.history ?? [] }, enabled: !!typeFilter, }) const list = useQuery({ queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }], queryFn: async () => { if (pendingMe) { const res = await api.get('/purchase-evaluations/inbox', { params: { type: typeFilter ?? undefined, approvalWorkflowId: approvalWorkflowId || undefined, }, }) return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length } } const res = await api.get>('/purchase-evaluations', { params: { pageSize: 50, search: search || undefined, type: typeFilter ?? undefined, phase: phase || undefined, approvalWorkflowId: approvalWorkflowId || undefined, }, }) return res.data }, }) const detail = useQuery({ queryKey: ['pe-detail', selectedId], queryFn: async () => (await api.get(`/purchase-evaluations/${selectedId}`)).data, enabled: !!selectedId, }) const del = useMutation({ mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`), onSuccess: () => { toast.success('Đã xóa phiếu.') // 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'] }) }, onError: e => toast.error(getErrorMessage(e)), }) 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]) // S79 — decouple "chọn" khỏi "mở rộng" (anh chốt 2026-06-19, annotated screenshot): // • Bấm dòng phiếu = chọn → detail INLINE 3-panel "như cũ" (8e68ed1). Trên màn // hẹp ( typeof window !== 'undefined' && window.innerWidth >= LG_BREAKPOINT // Chọn dòng: set id; clear expand. { 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 ?? [] // 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 → // FE filter để UX đúng kỳ vọng. Phân quyền strict V2 BE pending Session 18+. // S59 — anh Kiệt FDC (Zalo 2026-06-11) + anh chốt follow-up: tree 4 tầng // "Năm (tạo phiếu) > Dự án > Hạng mục công việc > Phiếu cần duyệt". // Hạng mục từ WorkItemId Mig 49, phiếu cũ chưa gắn → "(Chưa gắn hạng mục)". // NCC bỏ khỏi tree — vẫn hiện ở card/detail. Filter pendingMe TRƯỚC group. // Sort: Năm DESC + Dự án A-Z (vi) + Hạng mục A-Z (vi) + PE createdAt DESC. type WorkItemGroup = { workItemId: string | null workItemName: string items: PeListItem[] } type ProjectGroup = { projectId: string | null projectName: string projectCode: string workItems: WorkItemGroup[] totalCount: number } type YearGroup = { year: number projects: ProjectGroup[] totalCount: number } const yearGroups = useMemo(() => { const filtered = pendingMe ? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) : allRows const yearMap = new Map() for (const p of filtered) { const year = new Date(p.createdAt).getFullYear() if (!yearMap.has(year)) { yearMap.set(year, { year, projects: [], totalCount: 0 }) } const yg = yearMap.get(year)! const projKey = p.projectId ?? '__no_project__' const projName = p.projectName?.trim() || '(Dự án đã xoá)' let pg = yg.projects.find(g => (g.projectId ?? '__no_project__') === projKey) if (!pg) { pg = { projectId: p.projectId ?? null, projectName: projName, projectCode: p.projectCode || '', workItems: [], totalCount: 0 } yg.projects.push(pg) } const wiKey = p.workItemId ?? '__no_workitem__' const wiName = p.workItemName?.trim() || '(Chưa gắn hạng mục)' let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey) if (!wg) { wg = { workItemId: p.workItemId ?? null, workItemName: wiName, items: [] } pg.workItems.push(wg) } wg.items.push(p) pg.totalCount++ yg.totalCount++ } const arr = Array.from(yearMap.values()) arr.sort((a, b) => b.year - a.year) for (const yg of arr) { yg.projects.sort((a, b) => (a.projectCode || a.projectName).localeCompare(b.projectCode || b.projectName, 'vi')) for (const pg of yg.projects) { // S59 — numeric:true vì tên PMH bắt đầu bằng STT không pad ("2 Mat…" < "10 Mat…") pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi', { numeric: true })) for (const wg of pg.workItems) { wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) } } } return arr }, [allRows, pendingMe]) // Total row count cho header badge (pendingMe đếm filtered, Danh sách đếm BE total). const totalRowCount = yearGroups.reduce((sum, yg) => sum + yg.totalCount, 0) // Plan AG2 — Expand state localStorage Set. Default empty Set (all collapse). // S59 key v3: node scheme chốt "Năm > Dự án > Hạng mục" → key v1/v2 vô nghĩa. const STORAGE_KEY = 'pe_list_expanded_projects_v3' const [expandedSet, setExpandedSet] = useState>(() => { try { const raw = localStorage.getItem(STORAGE_KEY) return raw ? new Set(JSON.parse(raw) as string[]) : new Set() } catch { return new Set() } }) 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 ? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter]) : pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC' // ─── S79 Focus-mode overlay (anh chốt 2026-06-19) ───────────────────────── // Overlay full-bleed trượt từ phải CHE menu trái + TopBar + list + inline // detail. Chỉ mở khi `expand=1`. Thu gọn → bỏ expand (về inline detail). // Mount-then-animate: expand bật → mount overlay → next frame → translate-x-0. const overlayActive = !!selectedId && isExpand 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 (overlayActive) { 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) } } }, [overlayActive]) // 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 thu gọn + 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() collapseFocus() 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, collapseFocus]) return (

{headerTitle}

{pendingMe ? totalRowCount : (list.data?.total ?? 0)}
{/* S79 — list state: grid 3-panel bám trái lấp đầy màn hình. List
{/* ─── S79 Focus overlay (full-bleed, slide-from-right) ───────────────── Render CHỈ khi id && expand. Che hẳn menu trái + TopBar + list + inline detail. 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 → onApproved đóng hẳn (về list). Thu gọn / Esc / click backdrop → bỏ expand (về inline detail, GIỮ id). */} {focusMounted && (
{/* Backdrop mờ — fade riêng, click để thu gọn (mép trái còn hở ~chút trên màn cực rộng vì panel max-w). */}
)} ) } // Fullpage detail route cho mobile (/purchase-evaluations/:id) export function PurchaseEvaluationDetailPage() { const navigate = useNavigate() const id = location.pathname.split('/').pop()! const detail = useQuery({ queryKey: ['pe-detail', id], queryFn: async () => (await api.get(`/purchase-evaluations/${id}`)).data, }) const del = useMutation({ mutationFn: async () => api.delete(`/purchase-evaluations/${id}`), onSuccess: () => { toast.success('Đã xóa.') navigate('/purchase-evaluations') }, }) if (detail.isLoading) return
Đang tải…
if (!detail.data) return
Không tìm thấy phiếu.
return (
navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
) }