From 0bf6c7ec63fb7ce9fba7692173c05ae23848233c Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 21 May 2026 17:46:17 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-User+FE-Admin:=20Plan=20AG=20Chun?= =?UTF-8?q?k=20A+B+C=20=E2=80=94=20PE=20List=20tree=20view=202-level=20Pro?= =?UTF-8?q?ject=20>=20G=C3=B3i=20th=E1=BA=A7u?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAT feedback bro Tra Sol 2026-05-21: UI Duyệt NCC flat list "đám rừng" → tree view giống Outlook folder. Phase 1 FE-only mirror 2 app §3.9 — KHÔNG schema mới (Phase 2 ProjectPackage defer sau UAT confirm). Chunk A — Data transform useMemo group: - Map> - Normalize TenGoiThau: trim + toLowerCase, display raw đầu tiên trong group - Sort: Project A-Z + gói thầu A-Z (vi locale) - Fallback: "(Dự án đã xoá)" empty projectName + "(Chưa phân loại)" empty TenGoiThau - Filter (pendingMe → DaGuiDuyet) áp dụng TRƯỚC group Chunk B — UI render
/ 2-level: - Replace flat
  • bằng nested
    HTML native (no shadcn Accordion — gap component lib) - 📁 Project + 📄 Gói thầu icon + count badge inline - Chevron rotation via Tailwind group-open/proj + group-open/gt named groups - PE card content preserve nguyên (line 209-248 unchanged) Chunk C — Expand state localStorage persist: - Key 'pe_list_expanded_groups' Set - Project level key: projectId; Gói thầu level key: ${projectId}::${normalizedGoiThau} - Default empty Set (all collapse) — bro Tra Sol expect Outlook-style closed default Verify: - npm build fe-user PASS 0 TS err 1291.33 KB (gzip 337.00 KB) 1907 modules 16.05s - npm build fe-admin PASS 0 TS err 1402.68 KB (gzip 357.51 KB) 1926 modules 6.86s - KHÔNG BE change, KHÔNG Mig, KHÔNG test (UAT mode per feedback_uat_skip_verify) Pending: Reviewer pre-commit + CICD Run #222 verify Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/pe/PurchaseEvaluationsListPage.tsx | 231 +++++++++++++----- .../pages/pe/PurchaseEvaluationsListPage.tsx | 231 +++++++++++++----- 2 files changed, 346 insertions(+), 116 deletions(-) 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 => ( +
      • + +
      • + ))} +
      +
      + ) + })} +
      +
      ))} -
    +