[CLAUDE] PE Workflow V2: disable nút Duyệt nếu actor không trong cấp hiện tại
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m14s

User feedback: "Nếu không đúng bước duyệt thì nút duyệt cho Disable luôn cũng đc."

BE — DTO + Handler populate "Bước/Cấp đang chờ duyệt":
- Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs:
  +PurchaseEvaluationApprovalLevelApproverDto { UserId, FullName, Email }
  +PurchaseEvaluationCurrentApprovalDto { StepIndex, StepName,
    StepDepartmentId/Name, LevelOrder, LevelName, Approvers[] }
  PurchaseEvaluationDetailBundleDto +CurrentApproval? optional field
- Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs handler
  GetById: khi pin V2 + Phase=ChoDuyet → load AW.Steps.Levels Include
  3-level + group by Order = Cấp + resolve user names → populate
  CurrentApproval. Null khi V1 legacy hoặc không phải ChoDuyet.

FE — types + PeWorkflowPanel (cả 2 app mirror):
- types/purchaseEvaluation.ts: +PeCurrentApproval + PeCurrentApprovalLevelApprover
  + PeDetail.currentApproval optional
- PeWorkflowPanel:
  * Banner V2 hiển thị "Đang chờ Bước N (TênBước · Phòng X) — Cấp K"
    + list NV được duyệt + status emerald (đến lượt) / amber (không phải lượt)
  * useAuth() để check currentUser.id ∈ approvers + Admin bypass
  * Button "Duyệt forward" disabled khi V2 pin + actor không khớp.
    Title tooltip "Cấp K chỉ {NV X / NV Y} mới duyệt được."
  * Button "Trả lại" + "Từ chối" vẫn enabled (BE không gating 2 hành
    động này theo Cấp — Approver có thể reject bất cứ lúc nào).
  * Send-back logic update: target = DangSoanThao OR TraLai (V2 dùng TraLai)
- Admin role bypass mọi check.

Verify: 81 test pass · npm build × 2 OK · BE 0 error.

Test thử:
1. NV X (approver Cấp 1 V2) login → banner emerald "Đến lượt bạn duyệt"
   + nút "✓ Duyệt → ChoDuyet" enabled
2. NV Y (không phải approver) login → banner amber "Không phải lượt
   bạn — chỉ NV X mới duyệt được" + nút Duyệt grey disabled, hover tooltip
3. Admin login → bypass, button enabled
This commit is contained in:
pqhuy1987
2026-05-08 15:03:29 +07:00
parent b41484b702
commit d814429cee
6 changed files with 208 additions and 18 deletions

View File

@ -12,6 +12,7 @@ import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import {
ApprovalStage,
PurchaseEvaluationPhase,
@ -33,6 +34,16 @@ export function PeWorkflowPanel({
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
// duyệt cấp hiện tại. Admin bypass.
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorInV2Level = isAdmin
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
@ -107,33 +118,60 @@ export function PeWorkflowPanel({
})}
</ol>
{/* Mig 24 — V2 banner Bước/Cấp + danh sách NV duyệt */}
{isV2Pending && evaluation.currentApproval && !readOnly && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInV2Level
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {evaluation.currentApproval.stepIndex + 1} ({evaluation.currentApproval.stepName})
{evaluation.currentApproval.stepDepartmentName && <> · {evaluation.currentApproval.stepDepartmentName}</>}
{' — '}Cấp {evaluation.currentApproval.levelOrder}
</div>
<div className="mt-0.5">
NV duyệt: {evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ') || '(chưa có)'}
</div>
{actorInV2Level
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ NV trên mới duyệt đưc cấp này</div>}
</div>
)}
{next.length > 0 && !readOnly && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{next.map(p => {
// Phân loại button theo hành động:
// - Trả lại = về DangSoanThao (từ phase trung gian) — red
// - Hủy/Từ chối = TuChoi (chỉ ở phase DangSoanThao đầu) — red
// - Duyệt = forward phase tiếp theo — brand
const isSendBack = p === PurchaseEvaluationPhase.DangSoanThao
const isSendBack = (p === PurchaseEvaluationPhase.DangSoanThao || p === PurchaseEvaluationPhase.TraLai)
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
const isCancel = p === PurchaseEvaluationPhase.TuChoi
const isDanger = isSendBack || isCancel
const isForwardApprove = !isDanger
// Mig 24 — disable Duyệt forward nếu V2 pin + actor không trong cấp hiện tại
const isDisabled = isForwardApprove && blockedByV2Level
const label = isSendBack
? '← Trả lại (về Drafter sửa)'
: isCancel
? '✗ Hủy / Từ chối'
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[p]}`
const title = isDisabled && evaluation.currentApproval
? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới duyệt được.`
: undefined
return (
<button
key={p}
onClick={() => setTarget(p)}
onClick={() => !isDisabled && setTarget(p)}
disabled={isDisabled}
title={title}
className={cn(
'rounded border px-2 py-1 text-[11px] font-medium transition',
isDanger
? 'border-red-200 text-red-700 hover:bg-red-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
isDisabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400 hover:bg-slate-50',
!isDisabled && isDanger && 'border-red-200 text-red-700 hover:bg-red-50',
!isDisabled && !isDanger && 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{label}