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>
226 lines
9.3 KiB
TypeScript
226 lines
9.3 KiB
TypeScript
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
|
// Pulls nextPhases từ BE bundle (single source of truth).
|
|
import { useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import { Dialog } from '@/components/ui/Dialog'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Label } from '@/components/ui/Label'
|
|
import { Textarea } from '@/components/ui/Textarea'
|
|
import { api } from '@/lib/api'
|
|
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'
|
|
|
|
export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle }) {
|
|
const [target, setTarget] = useState<number | null>(null)
|
|
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`, {
|
|
targetPhase: target,
|
|
decision: target === BudgetPhase.TuChoi ? ApprovalDecision.Reject : ApprovalDecision.Approve,
|
|
comment: comment || null,
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('Đã chuyển phase.')
|
|
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('')
|
|
},
|
|
onError: e => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
const next = budget.workflow.nextPhases
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
|
|
<p className="mt-0.5 text-[11px] text-slate-500">{budget.workflow.policyDescription}</p>
|
|
</div>
|
|
|
|
<ol className="space-y-1.5">
|
|
{budget.workflow.activePhases
|
|
.filter(p => p !== BudgetPhase.TuChoi)
|
|
.map(p => {
|
|
const isCurrent = budget.phase === p
|
|
const isPast = isPastPhase(budget.phase, p, budget.workflow.activePhases)
|
|
return (
|
|
<li key={p}>
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
|
|
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
|
|
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
|
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
|
|
)}
|
|
>
|
|
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', BudgetPhaseColor[p])}>{p}</span>
|
|
<span className="truncate">{BudgetPhaseLabel[p]}</span>
|
|
{isCurrent && <span className="ml-auto text-[10px] text-brand-700">● hiện tại</span>}
|
|
{isPast && <span className="ml-auto text-[10px] text-emerald-600">✓</span>}
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ol>
|
|
|
|
{next.length > 0 && (
|
|
<div>
|
|
<Label className="text-xs">Chuyển tiếp:</Label>
|
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
{next.map(p => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setTarget(p)}
|
|
className={cn(
|
|
'rounded border px-2 py-1 text-[11px] transition',
|
|
p === BudgetPhase.TuChoi
|
|
? 'border-red-200 text-red-700 hover:bg-red-50'
|
|
: p === BudgetPhase.DangSoanThao
|
|
? 'border-amber-300 text-amber-700 hover:bg-amber-50'
|
|
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
|
|
)}
|
|
>
|
|
→ {BudgetPhaseLabel[p]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{target !== null && (
|
|
<Dialog
|
|
open
|
|
onClose={() => setTarget(null)}
|
|
title={`Chuyển → ${BudgetPhaseLabel[target]}`}
|
|
footer={<>
|
|
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
|
|
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
|
|
</>}
|
|
>
|
|
<Label>Ghi chú (tùy chọn)</Label>
|
|
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
|
</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>
|
|
|
|
<div className="border-t border-slate-200 pt-4">
|
|
<BudgetHistorySection budget={budget} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 có 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)
|
|
if (orderedIdx < 0 || currentIdx < 0) return false
|
|
return orderedIdx < currentIdx && p !== BudgetPhase.TuChoi
|
|
}
|