[CLAUDE] Infra+App+Api+FE: Chunk E4 — HĐ 2-stage dept approval (mirror PE)

Mở rộng 2-stage logic từ PE sang Contract workflow (Migration 16 đã có schema):

BE Service:
- ContractWorkflowService thêm UserManager<User> DI
- Mirror logic 2-stage từ PurchaseEvaluationWorkflowService.TransitionAsync
  Sau policy guard, trước gen mã HĐ:
  - User.DepartmentId != null + actor không admin/system + KHÔNG resume
    - DeptManager (TPB) → Stage=Confirm trực tiếp
    - CanBypassReview=true → Stage=Confirm + IsBypassed=true
    - Else (NV) → Stage=Review only, BLOCK transition
  - Insert ContractDepartmentApproval row (UPSERT theo UNIQUE)
  - Block transition khi chưa có Stage=Confirm:
    - Insert ContractApproval (FromPhase=ToPhase=fromPhase, [Review NV] comment)
    - Insert ContractChangelog "đã review, chờ TPB confirm"
    - Notify TPB cùng dept (UserManager filter DeptManager role)
    - Return early — phase KHÔNG đổi

App + Api:
- ContractDepartmentApprovalFeatures.cs (List query mirror PE)
- ContractsController endpoint GET /contracts/{id}/department-approvals

FE (cả fe-admin + fe-user):
- types/contracts.ts thêm ApprovalStage const + ContractDepartmentApproval type
- WorkflowHistoryPanel section "Tiến trình duyệt 2-cấp phòng ban":
  - Group by phase × dept, show Review NV + Confirm TPB
  - Highlight amber "chờ TPB confirm" khi current phase có Review chưa Confirm
  - Badge fuchsia "bypass" khi NV.CanBypassReview=true
  - Insert giữa WorkflowSummaryCard và Lịch sử duyệt
- Mirror cả 2 app (rule §3.9)

Use case mirror PE: HĐ ở phase DangGopY (P.CCM) — nv.cao (NV) duyệt thì
phase KHÔNG đổi (Review only), chờ ccm.tran (TPB) confirm mới sang DangXetDuyet.

Build: BE pass + FE pass cả 2 + 77 test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-04 13:43:05 +07:00
parent 4380bdc075
commit b6f5a16420
7 changed files with 447 additions and 5 deletions

View File

@ -2,19 +2,39 @@
// approval history (ai/khi/quyết định gì) + ContractChangelogsTab (mọi thay
// đổi header/detail/comment/attachment/transition) vào 1 stack. Dùng cho cả
// MyContracts 3-panel và ContractDetailPage fullpage.
import { ArrowRight, Clock, History } from 'lucide-react'
import { ArrowRight, Clock, History, Users2 } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { PhaseBadge } from '@/components/PhaseBadge'
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
import { ContractChangelogsTab } from '@/components/contracts/ContractChangelogsTab'
import type { ContractDetail } from '@/types/contracts'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
ApprovalStage,
ContractPhaseColor,
ContractPhaseLabel,
type ContractDepartmentApproval,
type ContractDetail,
} from '@/types/contracts'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtTime = (iso: string) =>
new Date(iso).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
const { data: deptApprovals = [] } = useQuery<ContractDepartmentApproval[]>({
queryKey: ['contract-dept-approvals', c.id],
queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data,
})
return (
<div className="space-y-4">
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
{deptApprovals.length > 0 && (
<DeptApprovalsSection rows={deptApprovals} currentPhase={c.phase} />
)}
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<Clock className="h-4 w-4" />
@ -54,3 +74,78 @@ export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail
</div>
)
}
// 2-stage dept approval timeline (Migration 16). Group by phase × dept. Same
// pattern PE — block phase transition khi current phase có Review nhưng chưa
// Confirm → highlight amber để user biết "đang chờ TPB confirm".
function DeptApprovalsSection({
rows,
currentPhase,
}: {
rows: ContractDepartmentApproval[]
currentPhase: number
}) {
const grouped = new Map<number, Map<string, ContractDepartmentApproval[]>>()
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)
}
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
return (
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
<Users2 className="h-4 w-4" />
Tiến trình duyệt 2-cấp phòng ban
</h2>
<p className="mb-3 text-xs text-slate-500">NV Review TPB Confirm. Phase chỉ chuyển khi Confirm.</p>
<div className="space-y-3">
{phaseOrder.map(phase => {
const byDept = grouped.get(phase)!
return (
<div key={phase}>
<div className={cn(
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
ContractPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
)}>
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
</div>
<div className="mt-1 space-y-1.5">
{[...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 (
<div key={deptId} className={cn(
'rounded border px-2 py-1.5 text-[11px]',
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
)}>
<div className="font-medium text-slate-700">{deptName}</div>
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
<span className="text-slate-500">Review:</span>
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
{review
? <> {review.approverName} <span className="text-slate-500"> {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
: '— chưa có'}
</span>
<span className="text-slate-500">Confirm:</span>
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
{confirm
? <> {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500"> {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
: '⏳ chờ TPB confirm'}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</section>
)
}

View File

@ -48,6 +48,29 @@ export const ApprovalDecision = {
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
// 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 (HĐ, 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 type ContractDepartmentApproval = {
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 ContractListItem = {
id: string
maHopDong: string | null