diff --git a/fe-admin/src/pages/system/UsersPage.tsx b/fe-admin/src/pages/system/UsersPage.tsx
index 00398ea..3801b05 100644
--- a/fe-admin/src/pages/system/UsersPage.tsx
+++ b/fe-admin/src/pages/system/UsersPage.tsx
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle, ShieldCheck } from 'lucide-react'
+import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle, ShieldCheck, FastForward } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
@@ -175,6 +175,19 @@ export function UsersPage() {
onError: err => toast.error(getErrorMessage(err)),
})
+ // F2 per-Drafter (Mig 29): toggle AllowDrafterSkipToFinal. Khi true, Drafter
+ // được tick "Gửi thẳng Cấp cuối" trong PE Workspace để skip Bước/Cấp trung
+ // gian và bay thẳng tới Cấp cuối workflow.
+ const allowSkipMut = useMutation({
+ mutationFn: (u: User) =>
+ api.patch(`/users/${u.id}/allow-skip-final`, { allowDrafterSkipToFinal: !u.allowDrafterSkipToFinal }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['users'] })
+ toast.success('Đã cập nhật quyền gửi thẳng Cấp cuối')
+ },
+ onError: err => toast.error(getErrorMessage(err)),
+ })
+
function nextPositionLevel(current: number | null): number | null {
// Cycle null → 1 (NV) → 2 (PP) → 3 (TP) → null
if (current == null) return 1
@@ -289,6 +302,21 @@ export function UsersPage() {
—
),
},
+ {
+ key: 'allowDrafterSkipToFinal',
+ header: 'Skip cuối',
+ width: 'w-20',
+ align: 'center',
+ render: u =>
+ u.allowDrafterSkipToFinal ? (
+
+
+ skip
+
+ ) : (
+ —
+ ),
+ },
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
{
key: 'actions',
@@ -334,6 +362,14 @@ export function UsersPage() {
{u.positionLevel != null ? PositionLevelShort[u.positionLevel] : '—'}
+
diff --git a/fe-admin/src/types/users.ts b/fe-admin/src/types/users.ts
index f41f4e5..749835f 100644
--- a/fe-admin/src/types/users.ts
+++ b/fe-admin/src/types/users.ts
@@ -11,6 +11,7 @@ export type User = {
position: string | null
canBypassReview: boolean
positionLevel: number | null // Mig 18 — 1=NV, 2=PP, 3=TP, null=admin/external
+ allowDrafterSkipToFinal: boolean // Mig 29 — F2: Drafter được gửi thẳng Cấp cuối workflow PE
}
// Cấp chức danh trong phòng (Mig 18) — phục vụ N-stage workflow inner step.
diff --git a/src/Backend/SolutionErp.Api/Controllers/UsersController.cs b/src/Backend/SolutionErp.Api/Controllers/UsersController.cs
index a2e9910..07ba7d7 100644
--- a/src/Backend/SolutionErp.Api/Controllers/UsersController.cs
+++ b/src/Backend/SolutionErp.Api/Controllers/UsersController.cs
@@ -84,9 +84,22 @@ public class UsersController(IMediator mediator) : ControllerBase
await mediator.Send(new SetUserPositionLevelCommand(id, body.PositionLevel), ct);
return NoContent();
}
+
+ // Mig 29 F2 per-Drafter: admin toggle AllowDrafterSkipToFinal cho user. Khi
+ // true, Drafter có thể tick "Gửi thẳng Cấp cuối" trong PE Workspace để bay
+ // thẳng tới Cấp cuối workflow. Mặc định false.
+ [HttpPatch("{id:guid}/allow-skip-final")]
+ [Authorize(Policy = "Users.Update")]
+ public async Task SetAllowDrafterSkipToFinal(
+ Guid id, [FromBody] SetAllowDrafterSkipToFinalBody body, CancellationToken ct)
+ {
+ await mediator.Send(new SetUserAllowDrafterSkipToFinalCommand(id, body.AllowDrafterSkipToFinal), ct);
+ return NoContent();
+ }
}
public record AssignRolesBody(List Roles);
public record ResetPasswordBody(string NewPassword);
public record SetBypassReviewBody(bool CanBypassReview);
public record SetPositionLevelBody(int? PositionLevel);
+public record SetAllowDrafterSkipToFinalBody(bool AllowDrafterSkipToFinal);
diff --git a/src/Backend/SolutionErp.Application/Users/UserFeatures.cs b/src/Backend/SolutionErp.Application/Users/UserFeatures.cs
index 056abe1..40e281c 100644
--- a/src/Backend/SolutionErp.Application/Users/UserFeatures.cs
+++ b/src/Backend/SolutionErp.Application/Users/UserFeatures.cs
@@ -22,7 +22,8 @@ public record UserDto(
string? DepartmentName,
string? Position,
bool CanBypassReview,
- int? PositionLevel); // Mig 18 — 1=NV, 2=PP, 3=TP. Null cho admin/system/external user.
+ int? PositionLevel, // Mig 18 — 1=NV, 2=PP, 3=TP. Null cho admin/system/external user.
+ bool AllowDrafterSkipToFinal); // Mig 29 — F2 per-Drafter: cho phép Drafter gửi thẳng Cấp cuối khi tạo PE.
// ========== LIST ==========
public record ListUsersQuery : PagedRequest, IRequest>;
@@ -61,7 +62,7 @@ public class ListUsersQueryHandler(UserManager userManager, IApplicationDb
var roles = await userManager.GetRolesAsync(u);
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now;
string? deptName = u.DepartmentId is { } did && deptNames.TryGetValue(did, out var dn) ? dn : null;
- items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel));
+ items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel, u.AllowDrafterSkipToFinal));
}
return new PagedResult(items, total, request.Page, request.PageSize);
@@ -83,7 +84,7 @@ public class GetUserQueryHandler(UserManager userManager, IApplicationDbCo
string? deptName = null;
if (u.DepartmentId is { } did)
deptName = await db.Departments.AsNoTracking().Where(d => d.Id == did).Select(d => d.Name).FirstOrDefaultAsync(ct);
- return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel);
+ return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel, u.AllowDrafterSkipToFinal);
}
}
@@ -322,3 +323,24 @@ public class SetUserPositionLevelCommandHandler(UserManager userManager)
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
}
}
+
+// ========== SET ALLOW DRAFTER SKIP TO FINAL (Mig 29 — F2 per-Drafter) ==========
+// Admin toggle AllowDrafterSkipToFinal cho 1 user. Khi true, user (Drafter) được
+// dùng checkbox "Gửi thẳng Cấp cuối" trong PE Workspace để skip toàn bộ Bước/Cấp
+// trung gian và bay thẳng tới Cấp cuối. Mặc định false (an toàn — Drafter phải
+// tuần tự qua mọi Bước/Cấp theo workflow).
+public record SetUserAllowDrafterSkipToFinalCommand(Guid Id, bool AllowDrafterSkipToFinal) : IRequest;
+
+public class SetUserAllowDrafterSkipToFinalCommandHandler(UserManager userManager)
+ : IRequestHandler
+{
+ public async Task Handle(SetUserAllowDrafterSkipToFinalCommand request, CancellationToken ct)
+ {
+ var user = await userManager.FindByIdAsync(request.Id.ToString())
+ ?? throw new NotFoundException("User", request.Id);
+ user.AllowDrafterSkipToFinal = request.AllowDrafterSkipToFinal;
+ var result = await userManager.UpdateAsync(user);
+ if (!result.Succeeded)
+ throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
+ }
+}