Files
solution-erp/fe-admin/src/pages/system/UsersPage.tsx
pqhuy1987 ae59cfeb5d
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m27s
[CLAUDE] FE-Admin: UsersPage dept/position field + RoleShortName tiếng Việt
## types/users.ts

- User type + departmentId/departmentName/position
- CreateUserInput + departmentId/position
- RoleShortName map (Mã viết tắt VN per role): QTV/NV.PB/TPB/PM/PRO/CCM/
  FIN/ACT/EQU/BOD/NĐUQ/HRA
- RoleLabel map (Tên đầy đủ VN per role)
- roleDisplayName(role) → "BOD — Ban Giám đốc" combined helper

## types/menu.ts

Role type + shortName field (mirror BE RoleDto).

## UsersPage redesign

- Column "Phòng ban" (departmentName + position 2 dòng)
- Column "Vai trò" hiển thị badge ShortName ("BOD", "CCM", "PM"), tooltip
  hover full label
- Column actions thêm "Sửa thông tin" (Pencil icon) — dialog edit dept/
  position/active state
- Create dialog 2-col grid: Email | Họ tên / Phòng ban (dropdown) | Chức vụ /
  Password (col-span-2). Roles checkboxes hiển thị "ShortName — full label"
- Edit dialog mới — sửa fullName + dept + position + isActive
- Roles dialog title kèm dept name (context cho user reviewer)
- toggleActive mutation include departmentId/position để không reset

## PermissionsPage

Panel 1 role list:
- 2-line per row: ShortName (semibold) + Description (truncate small)
- Tooltip = description đầy đủ
- Active row vẫn ring-brand-200

Panel 2 header badge: ShortName thay name code English.

## Build

fe-admin: tsc + vite pass (12.17s lần 1, 671ms lần 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:28:32 +07:00

485 lines
18 KiB
TypeScript

import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
import { PermissionGuard } from '@/components/PermissionGuard'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { MenuKeys } from '@/lib/menuKeys'
import type { Department, Paged } from '@/types/master'
import { AVAILABLE_ROLES, RoleShortName, RoleLabel, type User } from '@/types/users'
const fmtDate = (s: string) => new Date(s).toLocaleDateString('vi-VN')
type CreateForm = {
email: string
fullName: string
password: string
roles: string[]
departmentId: string
position: string
}
type EditForm = {
id: string
fullName: string
isActive: boolean
departmentId: string
position: string
}
const emptyCreate: CreateForm = { email: '', fullName: '', password: '', roles: [], departmentId: '', position: '' }
export function UsersPage() {
const qc = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<CreateForm>(emptyCreate)
const [editForm, setEditForm] = useState<EditForm | null>(null)
const [rolesModal, setRolesModal] = useState<User | null>(null)
const [roleSelection, setRoleSelection] = useState<string[]>([])
const [resetModal, setResetModal] = useState<User | null>(null)
const [newPassword, setNewPassword] = useState('')
const list = useQuery({
queryKey: ['users', { page, search }],
queryFn: async () =>
(await api.get<Paged<User>>('/users', { params: { page, pageSize: 20, search: search || undefined } })).data,
})
const departments = useQuery({
queryKey: ['departments-all'],
queryFn: async () => (await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const createMut = useMutation({
mutationFn: async () => {
await api.post('/users', {
email: createForm.email,
fullName: createForm.fullName,
password: createForm.password,
roles: createForm.roles,
departmentId: createForm.departmentId || null,
position: createForm.position || null,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
toast.success('Đã tạo user')
setCreateOpen(false)
setCreateForm(emptyCreate)
},
onError: err => toast.error(getErrorMessage(err)),
})
const editMut = useMutation({
mutationFn: async () => {
if (!editForm) return
await api.put(`/users/${editForm.id}`, {
id: editForm.id,
fullName: editForm.fullName,
isActive: editForm.isActive,
departmentId: editForm.departmentId || null,
position: editForm.position || null,
})
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
toast.success('Đã lưu')
setEditForm(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
const rolesMut = useMutation({
mutationFn: async () => {
if (!rolesModal) return
await api.put(`/users/${rolesModal.id}/roles`, { roles: roleSelection })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
toast.success('Đã cập nhật role')
setRolesModal(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
const resetMut = useMutation({
mutationFn: async () => {
if (!resetModal) return
await api.post(`/users/${resetModal.id}/reset-password`, { newPassword })
},
onSuccess: () => {
toast.success(`Đã reset password. User phải login lại.`)
setResetModal(null)
setNewPassword('')
},
onError: err => toast.error(getErrorMessage(err)),
})
const unlockMut = useMutation({
mutationFn: (id: string) => api.post(`/users/${id}/unlock`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
toast.success('Đã mở khóa')
},
onError: err => toast.error(getErrorMessage(err)),
})
const toggleActiveMut = useMutation({
mutationFn: (u: User) =>
api.put(`/users/${u.id}`, {
id: u.id, fullName: u.fullName, isActive: !u.isActive,
departmentId: u.departmentId, position: u.position,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] })
toast.success('Đã cập nhật trạng thái')
},
onError: err => toast.error(getErrorMessage(err)),
})
function openRoles(u: User) {
setRolesModal(u)
setRoleSelection([...u.roles])
}
function openEdit(u: User) {
setEditForm({
id: u.id, fullName: u.fullName, isActive: u.isActive,
departmentId: u.departmentId ?? '', position: u.position ?? '',
})
}
function toggleRole(r: string) {
setRoleSelection(sel => (sel.includes(r) ? sel.filter(x => x !== r) : [...sel, r]))
}
function toggleCreateRole(r: string) {
setCreateForm(f => ({
...f,
roles: f.roles.includes(r) ? f.roles.filter(x => x !== r) : [...f.roles, r],
}))
}
const columns: Column<User>[] = [
{ key: 'email', header: 'Email', render: u => <span className="font-mono text-xs">{u.email}</span> },
{ key: 'fullName', header: 'Họ tên', render: u => u.fullName },
{
key: 'departmentName',
header: 'Phòng ban',
width: 'w-44',
render: u => (
<div className="text-xs">
<div>{u.departmentName ?? <span className="text-slate-400"></span>}</div>
{u.position && <div className="text-slate-500">{u.position}</div>}
</div>
),
},
{
key: 'roles',
header: 'Vai trò',
render: u => (
<div className="flex flex-wrap gap-1">
{u.roles.length === 0 && <span className="text-xs text-slate-400"></span>}
{u.roles.map(r => (
<span
key={r}
className="rounded bg-brand-50 px-1.5 py-0.5 text-xs text-brand-700"
title={RoleLabel[r] ?? r}
>
{RoleShortName[r] ?? r}
</span>
))}
</div>
),
},
{
key: 'isActive',
header: 'Active',
width: 'w-20',
align: 'center',
render: u =>
u.isActive ? <CheckCircle2 className="mx-auto h-4 w-4 text-emerald-600" /> : <XCircle className="mx-auto h-4 w-4 text-slate-400" />,
},
{
key: 'isLocked',
header: 'Locked',
width: 'w-20',
align: 'center',
render: u =>
u.isLocked ? (
<span className="inline-flex items-center gap-1 text-xs text-red-600">
<KeyRound className="h-3.5 w-3.5" />
</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: 'actions',
header: '',
align: 'right',
width: 'w-56',
render: u => (
<div className="flex justify-end gap-1">
<PermissionGuard menuKey={MenuKeys.Users} action="Update">
<Button size="sm" variant="ghost" onClick={() => openEdit(u)} title="Sửa thông tin">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={() => openRoles(u)} title="Gán role">
<Shield className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={() => { setResetModal(u); setNewPassword('') }} title="Reset password">
<KeyRound className="h-3.5 w-3.5" />
</Button>
{u.isLocked && (
<Button size="sm" variant="ghost" onClick={() => unlockMut.mutate(u.id)} title="Mở khóa">
<Unlock className="h-3.5 w-3.5 text-amber-600" />
</Button>
)}
<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" />}
</Button>
</PermissionGuard>
</div>
),
},
]
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Users className="h-5 w-5" />
Người dùng
</span>
}
description="Tạo user + gán phòng ban + gán role để test quyền với non-admin."
actions={
<PermissionGuard menuKey={MenuKeys.Users} action="Create">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Thêm user
</Button>
</PermissionGuard>
}
/>
<div className="mb-3 flex gap-2">
<Input
placeholder="Tìm email hoặc tên…"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="max-w-sm"
/>
</div>
<DataTable columns={columns} rows={list.data?.items ?? []} getRowKey={u => u.id} isLoading={list.isLoading} />
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
{/* Create user */}
<Dialog
open={createOpen}
onClose={() => setCreateOpen(false)}
title="Thêm user mới"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Hủy</Button>
<Button onClick={(e: FormEvent) => { e.preventDefault(); createMut.mutate() }} disabled={createMut.isPending}>
{createMut.isPending ? 'Đang tạo…' : 'Tạo'}
</Button>
</>
}
>
<form className="space-y-4" onSubmit={e => { e.preventDefault(); createMut.mutate() }}>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Email *</Label>
<Input type="email" value={createForm.email} onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))} required />
</div>
<div className="space-y-1.5">
<Label>Họ tên *</Label>
<Input value={createForm.fullName} onChange={e => setCreateForm(f => ({ ...f, fullName: e.target.value }))} required />
</div>
<div className="space-y-1.5">
<Label>Phòng ban</Label>
<Select value={createForm.departmentId} onChange={e => setCreateForm(f => ({ ...f, departmentId: e.target.value }))}>
<option value=""> Chưa gán </option>
{departments.data?.map(d => (
<option key={d.id} value={d.id}>{d.code} {d.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Chức vụ</Label>
<Input
value={createForm.position}
onChange={e => setCreateForm(f => ({ ...f, position: e.target.value }))}
placeholder="vd: Trưởng phòng CCM"
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Password *</Label>
<Input type="password" value={createForm.password} onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))} required minLength={8} />
<div className="text-xs text-slate-500">Tối thiểu 8 tự + chữ hoa + thường + số + tự đc biệt</div>
</div>
</div>
<div className="space-y-1.5">
<Label>Vai trò ( Tên)</Label>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_ROLES.map(r => (
<label key={r} className="flex cursor-pointer items-center gap-2 rounded border border-slate-200 px-2 py-1 text-xs hover:bg-slate-50">
<input
type="checkbox"
className="accent-brand-600"
checked={createForm.roles.includes(r)}
onChange={() => toggleCreateRole(r)}
/>
<span className="font-semibold text-slate-700">{RoleShortName[r]}</span>
<span className="text-slate-500"> {RoleLabel[r]}</span>
</label>
))}
</div>
</div>
</form>
</Dialog>
{/* Edit user info */}
<Dialog
open={!!editForm}
onClose={() => setEditForm(null)}
title="Sửa thông tin user"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setEditForm(null)}>Hủy</Button>
<Button onClick={() => editMut.mutate()} disabled={editMut.isPending}>
{editMut.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</>
}
>
{editForm && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Họ tên *</Label>
<Input value={editForm.fullName} onChange={e => setEditForm(f => f && { ...f, fullName: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Phòng ban</Label>
<Select value={editForm.departmentId} onChange={e => setEditForm(f => f && { ...f, departmentId: e.target.value })}>
<option value=""> Chưa gán </option>
{departments.data?.map(d => (
<option key={d.id} value={d.id}>{d.code} {d.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Chức vụ</Label>
<Input
value={editForm.position}
onChange={e => setEditForm(f => f && { ...f, position: e.target.value })}
placeholder="vd: Trưởng phòng CCM"
/>
</div>
<div className="col-span-2 flex items-center gap-2 pt-2">
<input
type="checkbox"
id="edit-active"
className="h-4 w-4 accent-brand-600"
checked={editForm.isActive}
onChange={e => setEditForm(f => f && { ...f, isActive: e.target.checked })}
/>
<Label htmlFor="edit-active" className="cursor-pointer">Đang kích hoạt</Label>
</div>
</div>
)}
</Dialog>
{/* Assign roles */}
<Dialog
open={!!rolesModal}
onClose={() => setRolesModal(null)}
title={
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
Gán role cho {rolesModal?.fullName}
{rolesModal?.departmentName && (
<span className="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{rolesModal.departmentName}
</span>
)}
</span>
}
size="md"
footer={
<>
<Button variant="outline" onClick={() => setRolesModal(null)}>Hủy</Button>
<Button onClick={() => rolesMut.mutate()} disabled={rolesMut.isPending}>
{rolesMut.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_ROLES.map(r => (
<label key={r} className="flex cursor-pointer items-center gap-2 rounded border border-slate-200 px-2 py-1 text-xs hover:bg-slate-50">
<input
type="checkbox"
className="accent-brand-600"
checked={roleSelection.includes(r)}
onChange={() => toggleRole(r)}
/>
<span className="font-semibold text-slate-700">{RoleShortName[r]}</span>
<span className="text-slate-500"> {RoleLabel[r]}</span>
</label>
))}
</div>
</Dialog>
{/* Reset password */}
<Dialog
open={!!resetModal}
onClose={() => setResetModal(null)}
title={`Reset password: ${resetModal?.email}`}
size="sm"
footer={
<>
<Button variant="outline" onClick={() => setResetModal(null)}>Hủy</Button>
<Button
variant="danger"
onClick={() => resetMut.mutate()}
disabled={resetMut.isPending || newPassword.length < 8}
>
{resetMut.isPending ? 'Đang reset…' : 'Reset'}
</Button>
</>
}
>
<div className="space-y-3">
<div className="rounded bg-amber-50 px-3 py-2 text-xs text-amber-800">
User sẽ bị logout khỏi mọi session + phải login lại với password mới.
</div>
<div className="space-y-1.5">
<Label>Password mới</Label>
<Input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} minLength={8} />
</div>
</div>
</Dialog>
</div>
)
}