From 25837b6220f68340ae69b60b32907741cadcc78f Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 19 May 2026 11:00:05 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-User=20FE-Admin:=20Plan=20AC2=20?= =?UTF-8?q?=E2=80=94=20FE=20merge=20view=20recover=20historical=20Reject?= =?UTF-8?q?=20events=20PE=20c=C5=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bro UAT 2026-05-19 phản hồi sau Plan AC deploy: phiếu cũ PE/2026/A/032 vẫn KHÔNG show events Trả lại pre-deploy (Bro test trả lại Phan Văn Chương → Trà từ TRƯỚC cdfd542 không có trong Lịch sử duyệt). Root cause: Plan AC chỉ add Approval row cho events POST-deploy. Events pre-deploy chỉ có Changelog (LogTransitionAsync) — Approval table miss. Fix Plan AC2 — FE merge view (Option 2A bro chọn): ApprovalsTab fetch BOTH approvals + changelogs (cùng endpoint HistoryTab dùng): - Reconstruct synthetic PeApproval rows từ Changelog Workflow+Reject events: - Filter: entityType=Workflow(5) + summary "→ TraLai"/"→ TuChoi" OR contextNote chứa "Trả về"/"không lùi được" (3 mode OneLevel/OneStep/Assignee giữ ChoDuyet → distinguish qua ContextNote keywords) - Parse fromPhase/toPhase từ summary regex "Chuyển phase X → Y" - id prefix "syn-" để distinct vs real Approval rows - Dedupe synthetic vs real Reject Approval (post-Plan AC) qua approverUserId + timestamp 5s bucket key - Merge approvals + dedupedSynthetic → sort by approvedAt → render Reversible: KHÔNG touch DB, KHÔNG migration. FE-only fix recover history cho mọi PE cũ trước deploy. Mirror 2 app §3.9 identical logic. Verify: - npm build × fe-user + fe-admin PASS 0 TS err - BE/test unchanged from a734bf2 Co-Authored-By: Claude Opus 4.7 (1M context) --- fe-admin/src/components/pe/PeDetailTabs.tsx | 58 +++++++++++++++++++-- fe-user/src/components/pe/PeDetailTabs.tsx | 58 +++++++++++++++++++-- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index b642435..59b5424 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -2,7 +2,7 @@ // NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình. // Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel // → PeApprovalsSection + PeHistorySection). -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -30,6 +30,7 @@ import { PurchaseEvaluationTypeLabel, getPeDisplayStatus, isEditablePhase, + type PeApproval, type PeAttachment, type PeChangelog, type PeDepartmentOpinion, @@ -2013,10 +2014,61 @@ function decisionBadge(decision: number, toPhase: number): { label: string; cls: } function ApprovalsTab({ ev }: { ev: PeDetailBundle }) { - if (ev.approvals.length === 0) return

Chưa có bước duyệt nào.

+ // Plan AC2 S25 — FE merge view: fetch changelogs + reconstruct synthetic + // Reject rows từ pre-Plan AC historical data (PE cũ deploy trước 2026-05-19 + // KHÔNG có Approval row cho Reject vì BE cũ chỉ log Changelog). Merge approvals + // + synthetic + dedupe timestamp 5s bucket cùng approverUserId. + const changelogs = useQuery({ + queryKey: ['pe-changelog', ev.id], + queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data, + }) + + const merged = useMemo(() => { + const phaseEnumMap: Record = { + DangSoanThao: 1, ChoDuyet: 10, DaDuyet: 20, TraLai: 98, TuChoi: 99, + } + const PE_ENTITY_WORKFLOW = 5 + const syntheticRejects: PeApproval[] = (changelogs.data ?? []) + .filter(c => { + if (c.entityType !== PE_ENTITY_WORKFLOW) return false + if (c.summary?.includes('→ TraLai') || c.summary?.includes('→ TuChoi')) return true + // 3 mode (OneLevel/OneStep/Assignee) giữ ChoDuyet → distinguish qua ContextNote keywords + const note = c.contextNote ?? '' + return note.includes('Trả về') || note.includes('không lùi được') + }) + .map(c => { + const m = c.summary?.match(/Chuyển phase (\w+) → (\w+)/) + const fromPhase = m ? (phaseEnumMap[m[1]] ?? 10) : 10 + const toPhase = m ? (phaseEnumMap[m[2]] ?? 10) : 10 + return { + id: `syn-${c.id}`, + fromPhase, + toPhase, + approverUserId: c.userId ?? null, + approverName: c.userName ?? null, + decision: 2, + comment: c.contextNote ?? c.summary ?? null, + approvedAt: c.createdAt, + } + }) + + const realRejectKeys = new Set( + ev.approvals + .filter(a => a.decision === 2) + .map(a => `${a.approverUserId ?? ''}-${Math.floor(new Date(a.approvedAt).getTime() / 5000)}`), + ) + const dedupedSynthetic = syntheticRejects.filter(s => + !realRejectKeys.has(`${s.approverUserId ?? ''}-${Math.floor(new Date(s.approvedAt).getTime() / 5000)}`), + ) + + return [...ev.approvals, ...dedupedSynthetic] + .sort((a, b) => new Date(a.approvedAt).getTime() - new Date(b.approvedAt).getTime()) + }, [ev.approvals, changelogs.data]) + + if (merged.length === 0) return

Chưa có bước duyệt nào.

return (
    - {ev.approvals.map(a => { + {merged.map(a => { const dec = decisionBadge(a.decision, a.toPhase) return (
  1. diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 84a22cf..37c4f19 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -2,7 +2,7 @@ // NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình. // Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel // → PeApprovalsSection + PeHistorySection). -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -30,6 +30,7 @@ import { PurchaseEvaluationTypeLabel, getPeDisplayStatus, isEditablePhase, + type PeApproval, type PeAttachment, type PeChangelog, type PeDepartmentOpinion, @@ -2007,10 +2008,61 @@ function decisionBadge(decision: number, toPhase: number): { label: string; cls: } function ApprovalsTab({ ev }: { ev: PeDetailBundle }) { - if (ev.approvals.length === 0) return

    Chưa có bước duyệt nào.

    + // Plan AC2 S25 — FE merge view: fetch changelogs + reconstruct synthetic + // Reject rows từ pre-Plan AC historical data (PE cũ deploy trước 2026-05-19 + // KHÔNG có Approval row cho Reject vì BE cũ chỉ log Changelog). Merge approvals + // + synthetic + dedupe timestamp 5s bucket cùng approverUserId. + const changelogs = useQuery({ + queryKey: ['pe-changelog', ev.id], + queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data, + }) + + const merged = useMemo(() => { + const phaseEnumMap: Record = { + DangSoanThao: 1, ChoDuyet: 10, DaDuyet: 20, TraLai: 98, TuChoi: 99, + } + const PE_ENTITY_WORKFLOW = 5 + const syntheticRejects: PeApproval[] = (changelogs.data ?? []) + .filter(c => { + if (c.entityType !== PE_ENTITY_WORKFLOW) return false + if (c.summary?.includes('→ TraLai') || c.summary?.includes('→ TuChoi')) return true + // 3 mode (OneLevel/OneStep/Assignee) giữ ChoDuyet → distinguish qua ContextNote keywords + const note = c.contextNote ?? '' + return note.includes('Trả về') || note.includes('không lùi được') + }) + .map(c => { + const m = c.summary?.match(/Chuyển phase (\w+) → (\w+)/) + const fromPhase = m ? (phaseEnumMap[m[1]] ?? 10) : 10 + const toPhase = m ? (phaseEnumMap[m[2]] ?? 10) : 10 + return { + id: `syn-${c.id}`, + fromPhase, + toPhase, + approverUserId: c.userId ?? null, + approverName: c.userName ?? null, + decision: 2, + comment: c.contextNote ?? c.summary ?? null, + approvedAt: c.createdAt, + } + }) + + const realRejectKeys = new Set( + ev.approvals + .filter(a => a.decision === 2) + .map(a => `${a.approverUserId ?? ''}-${Math.floor(new Date(a.approvedAt).getTime() / 5000)}`), + ) + const dedupedSynthetic = syntheticRejects.filter(s => + !realRejectKeys.has(`${s.approverUserId ?? ''}-${Math.floor(new Date(s.approvedAt).getTime() / 5000)}`), + ) + + return [...ev.approvals, ...dedupedSynthetic] + .sort((a, b) => new Date(a.approvedAt).getTime() - new Date(b.approvedAt).getTime()) + }, [ev.approvals, changelogs.data]) + + if (merged.length === 0) return

    Chưa có bước duyệt nào.

    return (
      - {ev.approvals.map(a => { + {merged.map(a => { const dec = decisionBadge(a.decision, a.toPhase) return (