diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index e8cc461..a3eebf6 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -3,7 +3,7 @@ // action button. Approvals + History moved here from PeDetailTabs (2 section // dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items). import { useState } from 'react' -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Dialog } from '@/components/ui/Dialog' import { Button } from '@/components/ui/Button' @@ -13,9 +13,11 @@ import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { + ApprovalStage, PurchaseEvaluationPhase, PurchaseEvaluationPhaseColor, PurchaseEvaluationPhaseLabel, + type PeDepartmentApproval, type PeDetailBundle, } from '@/types/purchaseEvaluation' import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs' @@ -25,6 +27,16 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) const [comment, setComment] = useState('') const qc = useQueryClient() + // 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel + // hiển thị progress per phase × dept (Stage Review NV / Confirm TPB). + const { data: deptApprovals = [] } = useQuery({ + queryKey: ['pe-dept-approvals', evaluation.id], + queryFn: async () => { + const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`) + return r.data + }, + }) + const transition = useMutation({ mutationFn: async () => api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { @@ -36,6 +48,7 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) toast.success('Đã chuyển phase.') qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] }) qc.invalidateQueries({ queryKey: ['pe-list'] }) + qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] }) setTarget(null) setComment('') }, @@ -116,6 +129,12 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) )} + {deptApprovals.length > 0 && ( +
+ +
+ )} +
@@ -127,6 +146,85 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) ) } +// 2-stage dept approval timeline (Migration 16). Group by phase × dept, +// show Review NV row + Confirm TPB row. Highlight pending khi current phase +// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm". +function DeptApprovalsSection({ + rows, + currentPhase, +}: { + rows: PeDepartmentApproval[] + currentPhase: number +}) { + // Group: phase → dept → stages + const grouped = new Map>() + for (const r of rows) { + if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map()) + const byDept = grouped.get(r.phaseAtApproval)! + if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, []) + byDept.get(r.departmentId)!.push(r) + } + // Order: phase asc + const phaseOrder = [...grouped.keys()].sort((a, b) => a - b) + + return ( +
+

Tiến trình duyệt 2-cấp phòng ban

+

NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.

+
+ {phaseOrder.map(phase => { + const byDept = grouped.get(phase)! + return ( +
+
+ {PurchaseEvaluationPhaseLabel[phase]} +
+
+ {[...byDept.entries()].map(([deptId, stages]) => { + const review = stages.find(s => s.stage === ApprovalStage.Review) + const confirm = stages.find(s => s.stage === ApprovalStage.Confirm) + const deptName = stages[0]?.departmentName ?? '(không rõ phòng)' + const isPending = phase === currentPhase && review && !confirm + return ( +
+
{deptName}
+
+ Review: + + {review + ? <>✓ {review.approverName} — {fmtTime(review.approvedAt)}{review.comment && · "{review.comment}"} + : '— chưa có'} + + Confirm: + + {confirm + ? <>✓ {confirm.approverName}{confirm.isBypassed && bypass} — {fmtTime(confirm.approvedAt)}{confirm.comment && · "{confirm.comment}"} + : '⏳ chờ TPB confirm'} + +
+
+ ) + })} +
+
+ ) + })} +
+
+ ) +} + +function fmtTime(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + function isPastPhase(current: number, p: number, active: number[]): boolean { const orderedIdx = active.indexOf(p) const currentIdx = active.indexOf(current) diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts index 23e525e..a81353e 100644 --- a/fe-admin/src/types/purchaseEvaluation.ts +++ b/fe-admin/src/types/purchaseEvaluation.ts @@ -201,6 +201,34 @@ export type PeDepartmentOpinion = { userName: string | null } +// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB. +// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept). +// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review). +export const ApprovalStage = { + Review: 1, + Confirm: 2, +} as const +export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage] + +export const ApprovalStageLabel: Record = { + 1: 'Review NV', + 2: 'Confirm TPB', +} + +export type PeDepartmentApproval = { + id: string + phaseAtApproval: number + departmentId: string + departmentName: string | null + stage: number // 1=Review, 2=Confirm + approverUserId: string + approverName: string | null + approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)" + comment: string | null + approvedAt: string + isBypassed: boolean +} + export type PeDetailBundle = { id: string maPhieu: string | null diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx index e8cc461..a3eebf6 100644 --- a/fe-user/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx @@ -3,7 +3,7 @@ // action button. Approvals + History moved here from PeDetailTabs (2 section // dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items). import { useState } from 'react' -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Dialog } from '@/components/ui/Dialog' import { Button } from '@/components/ui/Button' @@ -13,9 +13,11 @@ import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { + ApprovalStage, PurchaseEvaluationPhase, PurchaseEvaluationPhaseColor, PurchaseEvaluationPhaseLabel, + type PeDepartmentApproval, type PeDetailBundle, } from '@/types/purchaseEvaluation' import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs' @@ -25,6 +27,16 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) const [comment, setComment] = useState('') const qc = useQueryClient() + // 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel + // hiển thị progress per phase × dept (Stage Review NV / Confirm TPB). + const { data: deptApprovals = [] } = useQuery({ + queryKey: ['pe-dept-approvals', evaluation.id], + queryFn: async () => { + const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`) + return r.data + }, + }) + const transition = useMutation({ mutationFn: async () => api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { @@ -36,6 +48,7 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) toast.success('Đã chuyển phase.') qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] }) qc.invalidateQueries({ queryKey: ['pe-list'] }) + qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] }) setTarget(null) setComment('') }, @@ -116,6 +129,12 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) )} + {deptApprovals.length > 0 && ( +
+ +
+ )} +
@@ -127,6 +146,85 @@ export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) ) } +// 2-stage dept approval timeline (Migration 16). Group by phase × dept, +// show Review NV row + Confirm TPB row. Highlight pending khi current phase +// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm". +function DeptApprovalsSection({ + rows, + currentPhase, +}: { + rows: PeDepartmentApproval[] + currentPhase: number +}) { + // Group: phase → dept → stages + const grouped = new Map>() + for (const r of rows) { + if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map()) + const byDept = grouped.get(r.phaseAtApproval)! + if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, []) + byDept.get(r.departmentId)!.push(r) + } + // Order: phase asc + const phaseOrder = [...grouped.keys()].sort((a, b) => a - b) + + return ( +
+

Tiến trình duyệt 2-cấp phòng ban

+

NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.

+
+ {phaseOrder.map(phase => { + const byDept = grouped.get(phase)! + return ( +
+
+ {PurchaseEvaluationPhaseLabel[phase]} +
+
+ {[...byDept.entries()].map(([deptId, stages]) => { + const review = stages.find(s => s.stage === ApprovalStage.Review) + const confirm = stages.find(s => s.stage === ApprovalStage.Confirm) + const deptName = stages[0]?.departmentName ?? '(không rõ phòng)' + const isPending = phase === currentPhase && review && !confirm + return ( +
+
{deptName}
+
+ Review: + + {review + ? <>✓ {review.approverName} — {fmtTime(review.approvedAt)}{review.comment && · "{review.comment}"} + : '— chưa có'} + + Confirm: + + {confirm + ? <>✓ {confirm.approverName}{confirm.isBypassed && bypass} — {fmtTime(confirm.approvedAt)}{confirm.comment && · "{confirm.comment}"} + : '⏳ chờ TPB confirm'} + +
+
+ ) + })} +
+
+ ) + })} +
+
+ ) +} + +function fmtTime(iso: string): string { + const d = new Date(iso) + return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + function isPastPhase(current: number, p: number, active: number[]): boolean { const orderedIdx = active.indexOf(p) const currentIdx = active.indexOf(current) diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts index 23e525e..a81353e 100644 --- a/fe-user/src/types/purchaseEvaluation.ts +++ b/fe-user/src/types/purchaseEvaluation.ts @@ -201,6 +201,34 @@ export type PeDepartmentOpinion = { userName: string | null } +// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB. +// BLOCK transition khi NV review chưa có TPB confirm cùng (PE, Phase, Dept). +// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review). +export const ApprovalStage = { + Review: 1, + Confirm: 2, +} as const +export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage] + +export const ApprovalStageLabel: Record = { + 1: 'Review NV', + 2: 'Confirm TPB', +} + +export type PeDepartmentApproval = { + id: string + phaseAtApproval: number + departmentId: string + departmentName: string | null + stage: number // 1=Review, 2=Confirm + approverUserId: string + approverName: string | null + approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)" + comment: string | null + approvedAt: string + isBypassed: boolean +} + export type PeDetailBundle = { id: string maPhieu: string | null