[CLAUDE] App+Api+FE: Chunk E5 — Budget 2-stage dept approval (mirror PE/Contract)

Budget complete the trifecta — đồng bộ pattern 2-stage cho 3 module
(Contract + PE + Budget) cùng UX cho user khi UAT.

BE App:
- TransitionBudgetCommandHandler thêm INotificationService + IDateTime DI
- Mirror logic 2-stage từ ContractWorkflowService:
  - actor.DepartmentId != null + KHÔNG admin/system + KHÔNG resume
    - DeptManager (TPB) hoặc CanBypassReview → Stage=Confirm
    - Else (NV) → Stage=Review only, BLOCK transition
  - Upsert BudgetDepartmentApproval (UNIQUE BudgetId+Phase+Dept+Stage)
  - Block khi !hasConfirm: insert Approval + Changelog + Notify TPB → return early
- BudgetDepartmentApprovalFeatures.cs (List query mirror PE/Contract)

Api:
- BudgetsController endpoint GET /budgets/{id}/department-approvals

FE (cả fe-admin + fe-user):
- types/budget.ts thêm ApprovalStage const + BudgetDepartmentApproval type
- BudgetWorkflowPanel 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" + badge fuchsia bypass

Note: low-priority cho Budget (ít user duyệt budget per dept) nhưng giữ
consistent UX 3 module.

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:46:56 +07:00
parent b6f5a16420
commit 1fc439b978
7 changed files with 425 additions and 3 deletions

View File

@ -1,7 +1,7 @@
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
// Pulls nextPhases từ BE bundle (single source of truth).
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'
@ -12,9 +12,11 @@ import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
ApprovalDecision,
ApprovalStage,
BudgetPhase,
BudgetPhaseColor,
BudgetPhaseLabel,
type BudgetDepartmentApproval,
type BudgetDetailBundle,
} from '@/types/budget'
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
@ -24,6 +26,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
const [comment, setComment] = useState('')
const qc = useQueryClient()
// 2-stage dept approvals (Migration 16) — fetch riêng để FE render timeline.
const { data: deptApprovals = [] } = useQuery<BudgetDepartmentApproval[]>({
queryKey: ['budget-dept-approvals', budget.id],
queryFn: async () => (await api.get(`/budgets/${budget.id}/department-approvals`)).data,
})
const transition = useMutation({
mutationFn: async () =>
api.post(`/budgets/${budget.id}/transitions`, {
@ -36,6 +44,7 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
qc.invalidateQueries({ queryKey: ['budget-detail', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-list'] })
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
setTarget(null)
setComment('')
},
@ -116,6 +125,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
</Dialog>
)}
{deptApprovals.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<BudgetDeptApprovalsSection rows={deptApprovals} currentPhase={budget.phase} />
</div>
)}
<div className="border-t border-slate-200 pt-4">
<BudgetApprovalsSection budget={budget} />
</div>
@ -127,6 +142,81 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
)
}
// 2-stage dept approval timeline (Migration 16) — mirror PE/Contract pattern.
function BudgetDeptApprovalsSection({
rows,
currentPhase,
}: {
rows: BudgetDepartmentApproval[]
currentPhase: number
}) {
const grouped = new Map<number, Map<string, BudgetDepartmentApproval[]>>()
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 (
<div>
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
<p className="mt-0.5 text-[11px] text-slate-500">NV Review TPB Confirm. Phase chỉ chuyển khi Confirm.</p>
<div className="mt-2 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',
BudgetPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
)}>
{BudgetPhaseLabel[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>
</div>
)
}
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)