[CLAUDE] App+FE-Admin: Chunk E3 — UserManager toggle CanBypassReview
Admin UI bật/tắt CanBypassReview per user (Migration 16):
- BE: UserDto thêm field CanBypassReview (List + Get queries)
- FE: User type thêm canBypassReview field
- UsersPage: column "Bypass" badge fuchsia khi true + button toggle ShieldCheck
(icon highlight fuchsia khi enabled, slate khi disabled)
- bypassMut PATCH /users/{id}/bypass-review { canBypassReview: !current }
Use case: phòng ban không có TPB hoặc TPB ủy quyền cho 1 NV cụ thể —
NV được Stage=Confirm trực tiếp (skip Stage Review), IsBypassed=true ghi audit.
Endpoint backend đã có sẵn ở Chunk E1 (commit 3c49316). Chỉ wire FE.
fe-user KHÔNG có UsersPage (admin-only function) — chỉ update fe-admin.
Build: BE pass + FE-admin pass + 77 test pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle } from 'lucide-react'
|
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle, ShieldCheck } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||||
@ -150,6 +150,19 @@ export function UsersPage() {
|
|||||||
onError: err => toast.error(getErrorMessage(err)),
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 2-stage dept approval (Migration 16): bật bypass cho NV → cho phép họ
|
||||||
|
// Confirm trực tiếp thay vì chỉ Review. Dùng cho phòng ban không có TPB
|
||||||
|
// hoặc TPB ủy quyền cho 1 NV cụ thể.
|
||||||
|
const bypassMut = useMutation({
|
||||||
|
mutationFn: (u: User) =>
|
||||||
|
api.patch(`/users/${u.id}/bypass-review`, { canBypassReview: !u.canBypassReview }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] })
|
||||||
|
toast.success('Đã cập nhật quyền bypass review')
|
||||||
|
},
|
||||||
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
|
})
|
||||||
|
|
||||||
function openRoles(u: User) {
|
function openRoles(u: User) {
|
||||||
setRolesModal(u)
|
setRolesModal(u)
|
||||||
setRoleSelection([...u.roles])
|
setRoleSelection([...u.roles])
|
||||||
@ -225,6 +238,21 @@ export function UsersPage() {
|
|||||||
<span className="text-xs text-slate-400">—</span>
|
<span className="text-xs text-slate-400">—</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'canBypassReview',
|
||||||
|
header: 'Bypass',
|
||||||
|
width: 'w-20',
|
||||||
|
align: 'center',
|
||||||
|
render: u =>
|
||||||
|
u.canBypassReview ? (
|
||||||
|
<span title="NV được Confirm trực tiếp (skip Review)" className="inline-flex items-center gap-1 rounded bg-fuchsia-100 px-1.5 py-0.5 text-[10px] text-fuchsia-700">
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
bypass
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
|
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@ -248,6 +276,14 @@ export function UsersPage() {
|
|||||||
<Unlock className="h-3.5 w-3.5 text-amber-600" />
|
<Unlock className="h-3.5 w-3.5 text-amber-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => bypassMut.mutate(u)}
|
||||||
|
title={u.canBypassReview ? 'Tắt bypass (cần Review NV trước)' : 'Bật bypass (NV được Confirm trực tiếp)'}
|
||||||
|
>
|
||||||
|
<ShieldCheck className={`h-3.5 w-3.5 ${u.canBypassReview ? 'text-fuchsia-600' : 'text-slate-400'}`} />
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
|
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
|
||||||
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export type User = {
|
|||||||
departmentId: string | null
|
departmentId: string | null
|
||||||
departmentName: string | null
|
departmentName: string | null
|
||||||
position: string | null
|
position: string | null
|
||||||
|
canBypassReview: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateUserInput = {
|
export type CreateUserInput = {
|
||||||
|
|||||||
@ -20,7 +20,8 @@ public record UserDto(
|
|||||||
List<string> Roles,
|
List<string> Roles,
|
||||||
Guid? DepartmentId,
|
Guid? DepartmentId,
|
||||||
string? DepartmentName,
|
string? DepartmentName,
|
||||||
string? Position);
|
string? Position,
|
||||||
|
bool CanBypassReview);
|
||||||
|
|
||||||
// ========== LIST ==========
|
// ========== LIST ==========
|
||||||
public record ListUsersQuery : PagedRequest, IRequest<PagedResult<UserDto>>;
|
public record ListUsersQuery : PagedRequest, IRequest<PagedResult<UserDto>>;
|
||||||
@ -59,7 +60,7 @@ public class ListUsersQueryHandler(UserManager<User> userManager, IApplicationDb
|
|||||||
var roles = await userManager.GetRolesAsync(u);
|
var roles = await userManager.GetRolesAsync(u);
|
||||||
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now;
|
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now;
|
||||||
string? deptName = u.DepartmentId is { } did && deptNames.TryGetValue(did, out var dn) ? dn : null;
|
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));
|
items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PagedResult<UserDto>(items, total, request.Page, request.PageSize);
|
return new PagedResult<UserDto>(items, total, request.Page, request.PageSize);
|
||||||
@ -81,7 +82,7 @@ public class GetUserQueryHandler(UserManager<User> userManager, IApplicationDbCo
|
|||||||
string? deptName = null;
|
string? deptName = null;
|
||||||
if (u.DepartmentId is { } did)
|
if (u.DepartmentId is { } did)
|
||||||
deptName = await db.Departments.AsNoTracking().Where(d => d.Id == did).Select(d => d.Name).FirstOrDefaultAsync(ct);
|
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);
|
return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user