diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx index 7b941d3..c086933 100644 --- a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -1,5 +1,9 @@ // 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' @@ -110,9 +114,79 @@ export function PurchaseEvaluationsListPage() { // 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+. - const rows = pendingMe - ? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) - : allRows + + // Plan AG Chunk A — Data transform useMemo group nested 2-level Project > Gói thầu + // Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group để empty state đúng. + // Normalize TenGoiThau: trim + toLowerCase làm group key, display raw đầu tiên. + // Fallback: "(Dự án đã xoá)" empty projectName + "(Chưa phân loại)" empty TenGoiThau. + // Sort vi locale A-Z cả 2 cấp. + type GoiThauGroup = { + displayName: string + normalizedKey: string + items: PeListItem[] + } + type ProjectGroup = { + projectId: string | null + projectName: string + goiThauList: GoiThauGroup[] + 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, goiThauList: [], totalCount: 0 }) + } + const pg = projectMap.get(projKey)! + const rawGT = (p.tenGoiThau ?? '').trim() + const normKey = rawGT.toLowerCase() || '__no_goithau__' + const displayGT = rawGT || '(Chưa phân loại)' + let gt = pg.goiThauList.find(g => g.normalizedKey === normKey) + if (!gt) { + gt = { displayName: displayGT, normalizedKey: normKey, items: [] } + pg.goiThauList.push(gt) + } + gt.items.push(p) + pg.totalCount++ + } + const arr = Array.from(projectMap.values()) + arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi')) + for (const pg of arr) { + pg.goiThauList.sort((a, b) => a.displayName.localeCompare(b.displayName, 'vi')) + } + 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 AG Chunk C — Expand state localStorage persist Set + // Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default. + // Project key: projectId or '__no_project__'; Gói thầu key: `${projectId}::${normalizedGoiThau}`. + const STORAGE_KEY = 'pe_list_expanded_groups' + 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]) @@ -125,7 +199,7 @@ export function PurchaseEvaluationsListPage() {

{headerTitle}

- {pendingMe ? rows.length : (list.data?.total ?? 0)} + {pendingMe ? totalRowCount : (list.data?.total ?? 0)} @@ -191,65 +265,106 @@ export function PurchaseEvaluationsListPage() { ))} )} - {!list.isLoading && rows.length === 0 && ( + {!list.isLoading && projectGroups.length === 0 && (
)} -
    - {rows.map(p => ( -
  • - -
  • + {/* Plan AG Chunk B — Tree view 2-level Project > Gói thầu > PE (Outlook-style). +
    / HTML native (no shadcn Accordion — gap component lib fe-user). + Tailwind v3 named groups group/proj + group/gt cho chevron rotation animation. + [&::-webkit-details-marker]:hidden ẩn default disclosure triangle browser. */} +
    + {projectGroups.map(pg => ( +
    toggleExpand(pg.projectId ?? '__no_project__', (e.currentTarget as HTMLDetailsElement).open)} + className="group/proj" + > + + + 📁 + {pg.projectName} + {pg.totalCount} + +
    + {pg.goiThauList.map(gt => { + const gtKey = `${pg.projectId ?? '__no_project__'}::${gt.normalizedKey}` + return ( +
    toggleExpand(gtKey, (e.currentTarget as HTMLDetailsElement).open)} + className="group/gt" + > + + + 📄 + {gt.displayName} + {gt.items.length} + +
      + {gt.items.map(p => ( +
    • + +
    • + ))} +
    +
    + ) + })} +
    +
    ))} -
+ diff --git a/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx index 7b941d3..c086933 100644 --- a/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx +++ b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx @@ -1,5 +1,9 @@ // 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' @@ -110,9 +114,79 @@ export function PurchaseEvaluationsListPage() { // 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+. - const rows = pendingMe - ? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet) - : allRows + + // Plan AG Chunk A — Data transform useMemo group nested 2-level Project > Gói thầu + // Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group để empty state đúng. + // Normalize TenGoiThau: trim + toLowerCase làm group key, display raw đầu tiên. + // Fallback: "(Dự án đã xoá)" empty projectName + "(Chưa phân loại)" empty TenGoiThau. + // Sort vi locale A-Z cả 2 cấp. + type GoiThauGroup = { + displayName: string + normalizedKey: string + items: PeListItem[] + } + type ProjectGroup = { + projectId: string | null + projectName: string + goiThauList: GoiThauGroup[] + 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, goiThauList: [], totalCount: 0 }) + } + const pg = projectMap.get(projKey)! + const rawGT = (p.tenGoiThau ?? '').trim() + const normKey = rawGT.toLowerCase() || '__no_goithau__' + const displayGT = rawGT || '(Chưa phân loại)' + let gt = pg.goiThauList.find(g => g.normalizedKey === normKey) + if (!gt) { + gt = { displayName: displayGT, normalizedKey: normKey, items: [] } + pg.goiThauList.push(gt) + } + gt.items.push(p) + pg.totalCount++ + } + const arr = Array.from(projectMap.values()) + arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi')) + for (const pg of arr) { + pg.goiThauList.sort((a, b) => a.displayName.localeCompare(b.displayName, 'vi')) + } + 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 AG Chunk C — Expand state localStorage persist Set + // Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default. + // Project key: projectId or '__no_project__'; Gói thầu key: `${projectId}::${normalizedGoiThau}`. + const STORAGE_KEY = 'pe_list_expanded_groups' + 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]) @@ -125,7 +199,7 @@ export function PurchaseEvaluationsListPage() {

{headerTitle}

- {pendingMe ? rows.length : (list.data?.total ?? 0)} + {pendingMe ? totalRowCount : (list.data?.total ?? 0)} @@ -191,65 +265,106 @@ export function PurchaseEvaluationsListPage() { ))} )} - {!list.isLoading && rows.length === 0 && ( + {!list.isLoading && projectGroups.length === 0 && (
)} -
    - {rows.map(p => ( -
  • - -
  • + {/* Plan AG Chunk B — Tree view 2-level Project > Gói thầu > PE (Outlook-style). +
    / HTML native (no shadcn Accordion — gap component lib fe-user). + Tailwind v3 named groups group/proj + group/gt cho chevron rotation animation. + [&::-webkit-details-marker]:hidden ẩn default disclosure triangle browser. */} +
    + {projectGroups.map(pg => ( +
    toggleExpand(pg.projectId ?? '__no_project__', (e.currentTarget as HTMLDetailsElement).open)} + className="group/proj" + > + + + 📁 + {pg.projectName} + {pg.totalCount} + +
    + {pg.goiThauList.map(gt => { + const gtKey = `${pg.projectId ?? '__no_project__'}::${gt.normalizedKey}` + return ( +
    toggleExpand(gtKey, (e.currentTarget as HTMLDetailsElement).open)} + className="group/gt" + > + + + 📄 + {gt.displayName} + {gt.items.length} + +
      + {gt.items.map(p => ( +
    • + +
    • + ))} +
    +
    + ) + })} +
    +
    ))} -
+