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 (