diff --git a/fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx b/fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx index 789cefb..a504492 100644 --- a/fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx +++ b/fe-admin/src/components/contracts/WorkflowHistoryPanel.tsx @@ -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({ + queryKey: ['contract-dept-approvals', c.id], + queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data, + }) + return (
{c.workflow && } + {deptApprovals.length > 0 && ( + + )} +

@@ -54,3 +74,78 @@ export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail

) } + +// 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>() + 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 ( +
+
+ {ContractPhaseLabel[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'} + +
+
+ ) + })} +
+
+ ) + })} +
+
+ ) +} diff --git a/fe-admin/src/types/contracts.ts b/fe-admin/src/types/contracts.ts index fdf04e1..cbe68f4 100644 --- a/fe-admin/src/types/contracts.ts +++ b/fe-admin/src/types/contracts.ts @@ -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 diff --git a/fe-user/src/components/contracts/WorkflowHistoryPanel.tsx b/fe-user/src/components/contracts/WorkflowHistoryPanel.tsx index 789cefb..a504492 100644 --- a/fe-user/src/components/contracts/WorkflowHistoryPanel.tsx +++ b/fe-user/src/components/contracts/WorkflowHistoryPanel.tsx @@ -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({ + queryKey: ['contract-dept-approvals', c.id], + queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data, + }) + return (
{c.workflow && } + {deptApprovals.length > 0 && ( + + )} +

@@ -54,3 +74,78 @@ export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail

) } + +// 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>() + 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 ( +
+
+ {ContractPhaseLabel[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'} + +
+
+ ) + })} +
+
+ ) + })} +
+
+ ) +} diff --git a/fe-user/src/types/contracts.ts b/fe-user/src/types/contracts.ts index fdf04e1..cbe68f4 100644 --- a/fe-user/src/types/contracts.ts +++ b/fe-user/src/types/contracts.ts @@ -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 diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs index 439173b..2fd3fe8 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractsController.cs @@ -218,6 +218,12 @@ public class ContractsController(IMediator mediator) : ControllerBase [HttpGet("{id:guid}/changelogs")] public async Task> GetChangelogs(Guid id, CancellationToken ct) => await mediator.Send(new ListContractChangelogsQuery(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 ListContractDepartmentApprovalsQuery(id), ct)); } public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment); diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractDepartmentApprovalFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractDepartmentApprovalFeatures.cs new file mode 100644 index 0000000..494aa07 --- /dev/null +++ b/src/Backend/SolutionErp.Application/Contracts/ContractDepartmentApprovalFeatures.cs @@ -0,0 +1,80 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Common; +using SolutionErp.Domain.Contracts; + +namespace SolutionErp.Application.Contracts; + +// 2-stage department approval list cho HĐ (Phase 9 — Migration 16). +// Mirror PE — query để FE hiển thị progress per phase × dept. +// +// FE Workflow Panel HĐ render dạng timeline: +// - Phase DangGopY (CCM): +// - Stage Review: nv.cao (NV) — 14:30 +// - Stage Confirm: ccm.tran (TPB) — 14:35 → unlock transition +// +// Insertion + Block logic ở ContractWorkflowService.TransitionAsync. + +public record ListContractDepartmentApprovalsQuery(Guid ContractId) + : IRequest>; + +public record ContractDepartmentApprovalDto( + Guid Id, + int PhaseAtApproval, + Guid DepartmentId, + string? DepartmentName, + ApprovalStage Stage, + Guid ApproverUserId, + string? ApproverName, + string? ApproverRoleSnapshot, + string? Comment, + DateTime ApprovedAt, + bool IsBypassed); + +public class ListContractDepartmentApprovalsQueryHandler(IApplicationDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + ListContractDepartmentApprovalsQuery request, CancellationToken ct) + { + var rows = await ( + from a in db.ContractDepartmentApprovals.AsNoTracking() + join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin + from d in deptJoin.DefaultIfEmpty() + where a.ContractId == request.ContractId + 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 ContractDepartmentApprovalDto( + 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.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index 9ec97ab..b1d3e0b 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -1,8 +1,10 @@ +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Interfaces; using SolutionErp.Application.Contracts.Services; using SolutionErp.Application.Notifications; +using SolutionErp.Domain.Common; using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Identity; using SolutionErp.Domain.Notifications; @@ -17,7 +19,8 @@ public class ContractWorkflowService( IContractCodeGenerator codeGenerator, IDateTime dateTime, INotificationService notifications, - IChangelogService changelog) : IContractWorkflowService + IChangelogService changelog, + UserManager userManager) : IContractWorkflowService { // Expose per-policy SLA via the contract — accepts optional contract so the // caller (CreateContractCommand) can ask for a specific type's SLA even @@ -105,6 +108,123 @@ public class ContractWorkflowService( } } + // ===== 2-stage department approval (Phase 9 — Migration 16) ===== + // Mirror PE workflow service. NV thuộc dept review → BLOCK transition + // cho đến khi TPB cùng dept confirm. CanBypassReview cho NV → đẩy thẳng + // Confirm (skip Review). Skip với reject + resume + admin + system. + if (decision == ApprovalDecision.Approve + && targetPhase != ContractPhase.DangSoanThao + && targetPhase != ContractPhase.TuChoi + && !isResumingAfterReject + && !isAdmin && !isSystem + && actorUserId is Guid actorUid) + { + var actor = await userManager.FindByIdAsync(actorUid.ToString()); + if (actor?.DepartmentId is Guid deptId) + { + var isManager = actorRoles.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"); + + // Upsert: 1 row mỗi (ContractId, phase, dept, stage). UNIQUE index enforce. + var existing = await db.ContractDepartmentApprovals + .FirstOrDefaultAsync(a => + a.ContractId == contract.Id + && a.PhaseAtApproval == (int)fromPhase + && a.DepartmentId == deptId + && a.Stage == stage, ct); + if (existing is null) + { + db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval + { + ContractId = contract.Id, + PhaseAtApproval = (int)fromPhase, + DepartmentId = deptId, + Stage = stage, + ApproverUserId = actorUid, + ApproverRoleSnapshot = roleSnapshot, + Comment = comment, + ApprovedAt = dateTime.UtcNow, + IsBypassed = isBypassed, + }); + } + else + { + existing.ApproverUserId = actorUid; + existing.ApproverRoleSnapshot = roleSnapshot; + existing.Comment = comment; + existing.ApprovedAt = dateTime.UtcNow; + existing.IsBypassed = isBypassed; + } + + var hasConfirm = stage == ApprovalStage.Confirm + || await db.ContractDepartmentApprovals.AnyAsync(a => + a.ContractId == contract.Id + && a.PhaseAtApproval == (int)fromPhase + && a.DepartmentId == deptId + && a.Stage == ApprovalStage.Confirm, ct); + + if (!hasConfirm) + { + // NV review xong, chưa có TPB confirm → BLOCK phase transition. + db.ContractApprovals.Add(new ContractApproval + { + ContractId = contract.Id, + FromPhase = fromPhase, + ToPhase = fromPhase, // không đổi phase + ApproverUserId = actorUid, + Decision = ApprovalDecision.Approve, + Comment = $"[Review NV] {comment ?? ""}", + ApprovedAt = dateTime.UtcNow, + }); + + string? reviewerName = (actor.FullName ?? actor.Email); + db.ContractChangelogs.Add(new ContractChangelog + { + ContractId = contract.Id, + EntityType = ChangelogEntityType.Workflow, + Action = ChangelogAction.Transition, + PhaseAtChange = fromPhase, + UserId = actorUid, + UserName = reviewerName ?? "Hệ thống", + Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm", + ContextNote = comment, + }); + + // Notify TPB cùng dept để confirm. 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: $"HĐ {contract.MaHopDong ?? contract.TenHopDong ?? ""} chờ TPB confirm", + description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.", + href: $"/contracts/{contract.Id}", + refId: contract.Id, + ct: ct); + } + } + catch { /* notification fail non-critical */ } + + await db.SaveChangesAsync(ct); + return; + } + } + } + // Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau. // Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip. // Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng