// 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+. // 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 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, 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.projectName.localeCompare(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' 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()} />
) }