// 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). // 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 { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'sonner' import { ClipboardCheck, 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 { 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 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') // 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.') setParam('id', null) qc.invalidateQueries({ queryKey: ['pe-list'] }) }, onError: e => toast.error(getErrorMessage(e)), }) function setParam(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' }) } function selectRow(id: string) { if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) { setParam('id', id) } else { navigate(`/purchase-evaluations/${id}`) } } 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+. // Plan AG5 — Group 3-level Project > Năm > NCC > PE (anh feedback 2026-05-21: // "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC"). Filter pendingMe TRƯỚC group. // Year extract từ createdAt.getFullYear(). NCC = selectedSupplierName fallback "(Chưa chọn NCC)" // khi PE chưa DaDuyet. Sort: Project A-Z (vi) + Year DESC + NCC A-Z (vi) + PE createdAt DESC. type SupplierGroup = { supplierId: string | null supplierName: string items: PeListItem[] } type YearGroup = { year: number suppliers: SupplierGroup[] totalCount: number } type ProjectGroup = { projectId: string | null projectName: string years: YearGroup[] totalCount: number } const projectGroups = useMemo(() => { const filtered = pendingMe ? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) : allRows const projectMap = new Map() 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, years: [], totalCount: 0 }) } const pg = projectMap.get(projKey)! const year = new Date(p.createdAt).getFullYear() let yg = pg.years.find(y => y.year === year) if (!yg) { yg = { year, suppliers: [], totalCount: 0 } pg.years.push(yg) } const supKey = p.selectedSupplierId ?? '__no_supplier__' const supName = p.selectedSupplierName?.trim() || '(Chưa chọn NCC)' let sg = yg.suppliers.find(s => (s.supplierId ?? '__no_supplier__') === supKey) if (!sg) { sg = { supplierId: p.selectedSupplierId ?? null, supplierName: supName, items: [] } yg.suppliers.push(sg) } sg.items.push(p) yg.totalCount++ pg.totalCount++ } const arr = Array.from(projectMap.values()) arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi')) for (const pg of arr) { pg.years.sort((a, b) => b.year - a.year) for (const yg of pg.years) { yg.suppliers.sort((a, b) => a.supplierName.localeCompare(b.supplierName, 'vi')) for (const sg of yg.suppliers) { sg.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 = projectGroups.reduce((sum, pg) => sum + pg.totalCount, 0) // Plan AG2 — Expand state localStorage Set (projectId only, drop ::gtKey suffix). // Default empty Set (all collapse). Single-PE project skip
wrapper (render flat). const STORAGE_KEY = 'pe_list_expanded_projects' 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' return (

{headerTitle}

{pendingMe ? totalRowCount : (list.data?.total ?? 0)}
{/* Panel 1: List */} {/* Panel 2: Detail tabs */}
{!selectedId && ( )} {selectedId && detail.isLoading &&
Đang tải…
} {selectedId && detail.data && ( setParam('id', null)} onDelete={() => del.mutate(detail.data!.id)} readOnly={true} /> )}
{/* 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. */}
) } // 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()} />
) }