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 (
-
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 (
-