diff --git a/fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx b/fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx index 095488a..9b33ec3 100644 --- a/fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx +++ b/fe-admin/src/components/budgets/BudgetWorkflowPanel.tsx @@ -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({ + 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 }) )} + {deptApprovals.length > 0 && ( +
+ +
+ )} +
@@ -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>() + 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 ( +
+

Tiến trình duyệt 2-cấp phòng ban

+

NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.

+
+ {phaseOrder.map(phase => { + const byDept = grouped.get(phase)! + return ( +
+
+ {BudgetPhaseLabel[phase] ?? `Phase ${phase}`} +
+
+ {[...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 ( +
+
{deptName}
+
+ Review: + + {review + ? <>✓ {review.approverName} — {fmtTime(review.approvedAt)}{review.comment && · "{review.comment}"} + : '— chưa có'} + + Confirm: + + {confirm + ? <>✓ {confirm.approverName}{confirm.isBypassed && bypass} — {fmtTime(confirm.approvedAt)}{confirm.comment && · "{confirm.comment}"} + : '⏳ chờ TPB confirm'} + +
+
+ ) + })} +
+
+ ) + })} +
+
+ ) +} + +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) diff --git a/fe-admin/src/types/budget.ts b/fe-admin/src/types/budget.ts index c3ee340..5eb972c 100644 --- a/fe-admin/src/types/budget.ts +++ b/fe-admin/src/types/budget.ts @@ -49,6 +49,27 @@ export const ApprovalDecision = { AutoApprove: 3, } 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 = { id: string maNganSach: string | null diff --git a/fe-user/src/components/budgets/BudgetWorkflowPanel.tsx b/fe-user/src/components/budgets/BudgetWorkflowPanel.tsx index 095488a..9b33ec3 100644 --- a/fe-user/src/components/budgets/BudgetWorkflowPanel.tsx +++ b/fe-user/src/components/budgets/BudgetWorkflowPanel.tsx @@ -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({ + 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 }) )} + {deptApprovals.length > 0 && ( +
+ +
+ )} +
@@ -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>() + 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 ( +
+

Tiến trình duyệt 2-cấp phòng ban

+

NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.

+
+ {phaseOrder.map(phase => { + const byDept = grouped.get(phase)! + return ( +
+
+ {BudgetPhaseLabel[phase] ?? `Phase ${phase}`} +
+
+ {[...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 ( +
+
{deptName}
+
+ Review: + + {review + ? <>✓ {review.approverName} — {fmtTime(review.approvedAt)}{review.comment && · "{review.comment}"} + : '— chưa có'} + + Confirm: + + {confirm + ? <>✓ {confirm.approverName}{confirm.isBypassed && bypass} — {fmtTime(confirm.approvedAt)}{confirm.comment && · "{confirm.comment}"} + : '⏳ chờ TPB confirm'} + +
+
+ ) + })} +
+
+ ) + })} +
+
+ ) +} + +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) diff --git a/fe-user/src/types/budget.ts b/fe-user/src/types/budget.ts index c3ee340..5eb972c 100644 --- a/fe-user/src/types/budget.ts +++ b/fe-user/src/types/budget.ts @@ -49,6 +49,27 @@ export const ApprovalDecision = { AutoApprove: 3, } 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 = { id: string maNganSach: string | null diff --git a/src/Backend/SolutionErp.Api/Controllers/BudgetsController.cs b/src/Backend/SolutionErp.Api/Controllers/BudgetsController.cs index 5f5508d..f1507ff 100644 --- a/src/Backend/SolutionErp.Api/Controllers/BudgetsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/BudgetsController.cs @@ -86,6 +86,12 @@ public class BudgetsController(IMediator mediator) : ControllerBase [HttpGet("{id:guid}/changelogs")] public async Task> GetChangelogs(Guid id, CancellationToken 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>> ListDepartmentApprovals( + Guid id, CancellationToken ct) + => Ok(await mediator.Send(new ListBudgetDepartmentApprovalsQuery(id), ct)); } public record TransitionBudgetBody(BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment); diff --git a/src/Backend/SolutionErp.Application/Budgets/BudgetDepartmentApprovalFeatures.cs b/src/Backend/SolutionErp.Application/Budgets/BudgetDepartmentApprovalFeatures.cs new file mode 100644 index 0000000..bf9b06b --- /dev/null +++ b/src/Backend/SolutionErp.Application/Budgets/BudgetDepartmentApprovalFeatures.cs @@ -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>; + +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> +{ + public async Task> 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(); + } +} diff --git a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs index 73d8ef6..03967ca 100644 --- a/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs +++ b/src/Backend/SolutionErp.Application/Budgets/BudgetFeatures.cs @@ -6,9 +6,12 @@ using SolutionErp.Application.Budgets.Dtos; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Common.Models; +using SolutionErp.Application.Notifications; using SolutionErp.Domain.Budgets; +using SolutionErp.Domain.Common; // ApprovalStage using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Notifications; namespace SolutionErp.Application.Budgets; @@ -121,7 +124,9 @@ public record TransitionBudgetCommand( public class TransitionBudgetCommandHandler( IApplicationDbContext db, ICurrentUser currentUser, - UserManager userManager) : IRequestHandler + UserManager userManager, + INotificationService notifications, + IDateTime dateTime) : IRequestHandler { public async Task Handle(TransitionBudgetCommand request, CancellationToken ct) { @@ -158,6 +163,121 @@ public class TransitionBudgetCommandHandler( throw new ForbiddenException( $"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.Phase = targetPhase; var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);