[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:
@ -1,7 +1,7 @@
|
|||||||
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
||||||
// Pulls nextPhases từ BE bundle (single source of truth).
|
// Pulls nextPhases từ BE bundle (single source of truth).
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -12,9 +12,11 @@ import { getErrorMessage } from '@/lib/apiError'
|
|||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
ApprovalDecision,
|
ApprovalDecision,
|
||||||
|
ApprovalStage,
|
||||||
BudgetPhase,
|
BudgetPhase,
|
||||||
BudgetPhaseColor,
|
BudgetPhaseColor,
|
||||||
BudgetPhaseLabel,
|
BudgetPhaseLabel,
|
||||||
|
type BudgetDepartmentApproval,
|
||||||
type BudgetDetailBundle,
|
type BudgetDetailBundle,
|
||||||
} from '@/types/budget'
|
} from '@/types/budget'
|
||||||
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
|
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
|
||||||
@ -24,6 +26,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
|||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const qc = useQueryClient()
|
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({
|
const transition = useMutation({
|
||||||
mutationFn: async () =>
|
mutationFn: async () =>
|
||||||
api.post(`/budgets/${budget.id}/transitions`, {
|
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-detail', budget.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||||
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
|
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
|
||||||
setTarget(null)
|
setTarget(null)
|
||||||
setComment('')
|
setComment('')
|
||||||
},
|
},
|
||||||
@ -116,6 +125,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
|||||||
</Dialog>
|
</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">
|
<div className="border-t border-slate-200 pt-4">
|
||||||
<BudgetApprovalsSection budget={budget} />
|
<BudgetApprovalsSection budget={budget} />
|
||||||
</div>
|
</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 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 {
|
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||||
const orderedIdx = active.indexOf(p)
|
const orderedIdx = active.indexOf(p)
|
||||||
const currentIdx = active.indexOf(current)
|
const currentIdx = active.indexOf(current)
|
||||||
|
|||||||
@ -49,6 +49,27 @@ export const ApprovalDecision = {
|
|||||||
AutoApprove: 3,
|
AutoApprove: 3,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||||
|
export const ApprovalStage = {
|
||||||
|
Review: 1,
|
||||||
|
Confirm: 2,
|
||||||
|
} as const
|
||||||
|
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||||
|
|
||||||
|
export type BudgetDepartmentApproval = {
|
||||||
|
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 BudgetListItem = {
|
export type BudgetListItem = {
|
||||||
id: string
|
id: string
|
||||||
maNganSach: string | null
|
maNganSach: string | null
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
// Panel 3 — workflow timeline + transition buttons + approval history + changelog.
|
||||||
// Pulls nextPhases từ BE bundle (single source of truth).
|
// Pulls nextPhases từ BE bundle (single source of truth).
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -12,9 +12,11 @@ import { getErrorMessage } from '@/lib/apiError'
|
|||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
ApprovalDecision,
|
ApprovalDecision,
|
||||||
|
ApprovalStage,
|
||||||
BudgetPhase,
|
BudgetPhase,
|
||||||
BudgetPhaseColor,
|
BudgetPhaseColor,
|
||||||
BudgetPhaseLabel,
|
BudgetPhaseLabel,
|
||||||
|
type BudgetDepartmentApproval,
|
||||||
type BudgetDetailBundle,
|
type BudgetDetailBundle,
|
||||||
} from '@/types/budget'
|
} from '@/types/budget'
|
||||||
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
|
import { BudgetApprovalsSection, BudgetHistorySection } from './BudgetDetailTabs'
|
||||||
@ -24,6 +26,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
|||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const qc = useQueryClient()
|
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({
|
const transition = useMutation({
|
||||||
mutationFn: async () =>
|
mutationFn: async () =>
|
||||||
api.post(`/budgets/${budget.id}/transitions`, {
|
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-detail', budget.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
qc.invalidateQueries({ queryKey: ['budget-list'] })
|
||||||
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
|
qc.invalidateQueries({ queryKey: ['budget-changelog', budget.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['budget-dept-approvals', budget.id] })
|
||||||
setTarget(null)
|
setTarget(null)
|
||||||
setComment('')
|
setComment('')
|
||||||
},
|
},
|
||||||
@ -116,6 +125,12 @@ export function BudgetWorkflowPanel({ budget }: { budget: BudgetDetailBundle })
|
|||||||
</Dialog>
|
</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">
|
<div className="border-t border-slate-200 pt-4">
|
||||||
<BudgetApprovalsSection budget={budget} />
|
<BudgetApprovalsSection budget={budget} />
|
||||||
</div>
|
</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 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 {
|
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||||
const orderedIdx = active.indexOf(p)
|
const orderedIdx = active.indexOf(p)
|
||||||
const currentIdx = active.indexOf(current)
|
const currentIdx = active.indexOf(current)
|
||||||
|
|||||||
@ -49,6 +49,27 @@ export const ApprovalDecision = {
|
|||||||
AutoApprove: 3,
|
AutoApprove: 3,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||||
|
export const ApprovalStage = {
|
||||||
|
Review: 1,
|
||||||
|
Confirm: 2,
|
||||||
|
} as const
|
||||||
|
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||||
|
|
||||||
|
export type BudgetDepartmentApproval = {
|
||||||
|
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 BudgetListItem = {
|
export type BudgetListItem = {
|
||||||
id: string
|
id: string
|
||||||
maNganSach: string | null
|
maNganSach: string | null
|
||||||
|
|||||||
@ -86,6 +86,12 @@ public class BudgetsController(IMediator mediator) : ControllerBase
|
|||||||
[HttpGet("{id:guid}/changelogs")]
|
[HttpGet("{id:guid}/changelogs")]
|
||||||
public async Task<List<BudgetChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
public async Task<List<BudgetChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
||||||
=> await mediator.Send(new ListBudgetChangelogsQuery(id), ct);
|
=> await mediator.Send(new ListBudgetChangelogsQuery(id), ct);
|
||||||
|
|
||||||
|
// 2-stage department approval list (Phase 9 — Migration 16).
|
||||||
|
[HttpGet("{id:guid}/department-approvals")]
|
||||||
|
public async Task<ActionResult<List<BudgetDepartmentApprovalDto>>> ListDepartmentApprovals(
|
||||||
|
Guid id, CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new ListBudgetDepartmentApprovalsQuery(id), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TransitionBudgetBody(BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
public record TransitionBudgetBody(BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Budgets;
|
||||||
|
|
||||||
|
// 2-stage department approval list cho Budget (Phase 9 — Migration 16).
|
||||||
|
// Mirror PE/Contract — query để FE Workflow Panel render timeline progress.
|
||||||
|
//
|
||||||
|
// Insertion + Block logic ở TransitionBudgetCommandHandler (BudgetFeatures.cs).
|
||||||
|
|
||||||
|
public record ListBudgetDepartmentApprovalsQuery(Guid BudgetId)
|
||||||
|
: IRequest<List<BudgetDepartmentApprovalDto>>;
|
||||||
|
|
||||||
|
public record BudgetDepartmentApprovalDto(
|
||||||
|
Guid Id,
|
||||||
|
int PhaseAtApproval,
|
||||||
|
Guid DepartmentId,
|
||||||
|
string? DepartmentName,
|
||||||
|
ApprovalStage Stage,
|
||||||
|
Guid ApproverUserId,
|
||||||
|
string? ApproverName,
|
||||||
|
string? ApproverRoleSnapshot,
|
||||||
|
string? Comment,
|
||||||
|
DateTime ApprovedAt,
|
||||||
|
bool IsBypassed);
|
||||||
|
|
||||||
|
public class ListBudgetDepartmentApprovalsQueryHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<ListBudgetDepartmentApprovalsQuery, List<BudgetDepartmentApprovalDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<BudgetDepartmentApprovalDto>> Handle(
|
||||||
|
ListBudgetDepartmentApprovalsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rows = await (
|
||||||
|
from a in db.BudgetDepartmentApprovals.AsNoTracking()
|
||||||
|
join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin
|
||||||
|
from d in deptJoin.DefaultIfEmpty()
|
||||||
|
where a.BudgetId == request.BudgetId
|
||||||
|
orderby a.PhaseAtApproval, a.Stage, a.ApprovedAt
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
a.Id,
|
||||||
|
a.PhaseAtApproval,
|
||||||
|
a.DepartmentId,
|
||||||
|
DepartmentName = d != null ? d.Name : null,
|
||||||
|
a.Stage,
|
||||||
|
a.ApproverUserId,
|
||||||
|
a.ApproverRoleSnapshot,
|
||||||
|
a.Comment,
|
||||||
|
a.ApprovedAt,
|
||||||
|
a.IsBypassed,
|
||||||
|
}).ToListAsync(ct);
|
||||||
|
|
||||||
|
var userIds = rows.Select(r => r.ApproverUserId).Distinct().ToList();
|
||||||
|
var users = await db.Users.AsNoTracking()
|
||||||
|
.Where(u => userIds.Contains(u.Id))
|
||||||
|
.Select(u => new { u.Id, Name = u.FullName ?? u.Email ?? "" })
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.Name, ct);
|
||||||
|
|
||||||
|
return rows.Select(r => new BudgetDepartmentApprovalDto(
|
||||||
|
r.Id,
|
||||||
|
r.PhaseAtApproval,
|
||||||
|
r.DepartmentId,
|
||||||
|
r.DepartmentName,
|
||||||
|
r.Stage,
|
||||||
|
r.ApproverUserId,
|
||||||
|
users.TryGetValue(r.ApproverUserId, out var n) ? n : null,
|
||||||
|
r.ApproverRoleSnapshot,
|
||||||
|
r.Comment,
|
||||||
|
r.ApprovedAt,
|
||||||
|
r.IsBypassed)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,9 +6,12 @@ using SolutionErp.Application.Budgets.Dtos;
|
|||||||
using SolutionErp.Application.Common.Exceptions;
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
using SolutionErp.Application.Common.Interfaces;
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
using SolutionErp.Application.Common.Models;
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
using SolutionErp.Domain.Budgets;
|
using SolutionErp.Domain.Budgets;
|
||||||
|
using SolutionErp.Domain.Common; // ApprovalStage
|
||||||
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction
|
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
|
||||||
namespace SolutionErp.Application.Budgets;
|
namespace SolutionErp.Application.Budgets;
|
||||||
|
|
||||||
@ -121,7 +124,9 @@ public record TransitionBudgetCommand(
|
|||||||
public class TransitionBudgetCommandHandler(
|
public class TransitionBudgetCommandHandler(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
UserManager<User> userManager) : IRequestHandler<TransitionBudgetCommand>
|
UserManager<User> userManager,
|
||||||
|
INotificationService notifications,
|
||||||
|
IDateTime dateTime) : IRequestHandler<TransitionBudgetCommand>
|
||||||
{
|
{
|
||||||
public async Task Handle(TransitionBudgetCommand request, CancellationToken ct)
|
public async Task Handle(TransitionBudgetCommand request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@ -158,6 +163,121 @@ public class TransitionBudgetCommandHandler(
|
|||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
|
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
|
||||||
|
|
||||||
|
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
|
||||||
|
// Mirror PE/Contract. Low-priority cho Budget vì ít dept duyệt budget,
|
||||||
|
// nhưng giữ consistent UX 3 module.
|
||||||
|
if (request.Decision == ApprovalDecision.Approve
|
||||||
|
&& targetPhase != BudgetPhase.DangSoanThao
|
||||||
|
&& targetPhase != BudgetPhase.TuChoi
|
||||||
|
&& !isResumingAfterReject
|
||||||
|
&& !isAdmin
|
||||||
|
&& currentUser.UserId is Guid actorUid)
|
||||||
|
{
|
||||||
|
var actor = await userManager.FindByIdAsync(actorUid.ToString());
|
||||||
|
if (actor?.DepartmentId is Guid deptId)
|
||||||
|
{
|
||||||
|
var isManager = currentUser.Roles.Contains(AppRoles.DeptManager);
|
||||||
|
var canBypass = actor.CanBypassReview;
|
||||||
|
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
|
||||||
|
var isBypassed = !isManager && canBypass;
|
||||||
|
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
|
||||||
|
|
||||||
|
var existing = await db.BudgetDepartmentApprovals
|
||||||
|
.FirstOrDefaultAsync(a =>
|
||||||
|
a.BudgetId == entity.Id
|
||||||
|
&& a.PhaseAtApproval == (int)fromPhase
|
||||||
|
&& a.DepartmentId == deptId
|
||||||
|
&& a.Stage == stage, ct);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.BudgetDepartmentApprovals.Add(new BudgetDepartmentApproval
|
||||||
|
{
|
||||||
|
BudgetId = entity.Id,
|
||||||
|
PhaseAtApproval = (int)fromPhase,
|
||||||
|
DepartmentId = deptId,
|
||||||
|
Stage = stage,
|
||||||
|
ApproverUserId = actorUid,
|
||||||
|
ApproverRoleSnapshot = roleSnapshot,
|
||||||
|
Comment = request.Comment,
|
||||||
|
ApprovedAt = dateTime.UtcNow,
|
||||||
|
IsBypassed = isBypassed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.ApproverUserId = actorUid;
|
||||||
|
existing.ApproverRoleSnapshot = roleSnapshot;
|
||||||
|
existing.Comment = request.Comment;
|
||||||
|
existing.ApprovedAt = dateTime.UtcNow;
|
||||||
|
existing.IsBypassed = isBypassed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasConfirm = stage == ApprovalStage.Confirm
|
||||||
|
|| await db.BudgetDepartmentApprovals.AnyAsync(a =>
|
||||||
|
a.BudgetId == entity.Id
|
||||||
|
&& a.PhaseAtApproval == (int)fromPhase
|
||||||
|
&& a.DepartmentId == deptId
|
||||||
|
&& a.Stage == ApprovalStage.Confirm, ct);
|
||||||
|
|
||||||
|
if (!hasConfirm)
|
||||||
|
{
|
||||||
|
// BLOCK transition. Log audit Approval + Changelog.
|
||||||
|
db.BudgetApprovals.Add(new BudgetApproval
|
||||||
|
{
|
||||||
|
BudgetId = entity.Id,
|
||||||
|
FromPhase = fromPhase,
|
||||||
|
ToPhase = fromPhase,
|
||||||
|
ApproverUserId = actorUid,
|
||||||
|
Decision = ApprovalDecision.Approve,
|
||||||
|
Comment = $"[Review NV] {request.Comment ?? ""}",
|
||||||
|
ApprovedAt = dateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
string? reviewerName = (actor.FullName ?? actor.Email);
|
||||||
|
db.BudgetChangelogs.Add(new BudgetChangelog
|
||||||
|
{
|
||||||
|
BudgetId = entity.Id,
|
||||||
|
EntityType = BudgetEntityType.Workflow,
|
||||||
|
Action = ChangelogAction.Transition,
|
||||||
|
PhaseAtChange = fromPhase,
|
||||||
|
UserId = actorUid,
|
||||||
|
UserName = reviewerName ?? "Hệ thống",
|
||||||
|
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
|
||||||
|
ContextNote = request.Comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify TPB cùng dept. Best effort.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var managers = await db.Users.AsNoTracking()
|
||||||
|
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var mgrId in managers)
|
||||||
|
{
|
||||||
|
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
|
||||||
|
if (mgr is null) continue;
|
||||||
|
var roles = await userManager.GetRolesAsync(mgr);
|
||||||
|
if (!roles.Contains(AppRoles.DeptManager)) continue;
|
||||||
|
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
mgrId,
|
||||||
|
NotificationType.ContractPhaseTransition,
|
||||||
|
title: $"NS {entity.MaNganSach ?? entity.TenNganSach} chờ TPB confirm",
|
||||||
|
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
|
||||||
|
href: $"/budgets/{entity.Id}",
|
||||||
|
refId: entity.Id,
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* notification fail non-critical */ }
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entity.SlaWarningSent = false;
|
entity.SlaWarningSent = false;
|
||||||
entity.Phase = targetPhase;
|
entity.Phase = targetPhase;
|
||||||
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
||||||
|
|||||||
Reference in New Issue
Block a user