[CLAUDE] FE-Admin: UsersPage dept/position field + RoleShortName tiếng Việt
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m27s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m27s
## 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>
This commit is contained in:
@ -130,13 +130,19 @@ export function PermissionsPage() {
|
|||||||
key={r.id}
|
key={r.id}
|
||||||
onClick={() => setRoleId(r.id)}
|
onClick={() => setRoleId(r.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition',
|
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left transition',
|
||||||
roleId === r.id
|
roleId === r.id
|
||||||
? 'bg-brand-50 text-brand-700 font-medium ring-1 ring-brand-200'
|
? 'bg-brand-50 text-brand-700 font-medium ring-1 ring-brand-200'
|
||||||
: 'text-slate-600 hover:bg-slate-50',
|
: 'text-slate-600 hover:bg-slate-50',
|
||||||
)}
|
)}
|
||||||
|
title={r.description ?? r.name}
|
||||||
>
|
>
|
||||||
<span className="truncate">{r.name}</span>
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-semibold">{r.shortName ?? r.name}</span>
|
||||||
|
<span className="block truncate text-[11px] font-normal text-slate-500">
|
||||||
|
{r.description ?? r.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{roleId === r.id && <Check className="h-3.5 w-3.5 shrink-0" />}
|
{roleId === r.id && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -149,8 +155,8 @@ export function PermissionsPage() {
|
|||||||
<KeyRound className="h-4 w-4 text-brand-600" />
|
<KeyRound className="h-4 w-4 text-brand-600" />
|
||||||
<h2 className="text-sm font-semibold text-slate-700">2. Quyền theo menu</h2>
|
<h2 className="text-sm font-semibold text-slate-700">2. Quyền theo menu</h2>
|
||||||
{selectedRole && (
|
{selectedRole && (
|
||||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700">
|
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700" title={selectedRole.description ?? ''}>
|
||||||
{selectedRole.name}
|
{selectedRole.shortName ?? selectedRole.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto w-56">
|
<div className="ml-auto w-56">
|
||||||
|
|||||||
@ -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 { KeyRound, Plus, Shield, Unlock, Users, CheckCircle2, XCircle } from 'lucide-react'
|
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle } 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'
|
||||||
@ -8,22 +8,43 @@ import { PermissionGuard } from '@/components/PermissionGuard'
|
|||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { MenuKeys } from '@/lib/menuKeys'
|
import { MenuKeys } from '@/lib/menuKeys'
|
||||||
import type { Paged } from '@/types/master'
|
import type { Department, Paged } from '@/types/master'
|
||||||
import { AVAILABLE_ROLES, RoleLabel, type User } from '@/types/users'
|
import { AVAILABLE_ROLES, RoleShortName, RoleLabel, type User } from '@/types/users'
|
||||||
|
|
||||||
const fmtDate = (s: string) => new Date(s).toLocaleDateString('vi-VN')
|
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() {
|
export function UsersPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [createForm, setCreateForm] = useState({ email: '', fullName: '', password: '', roles: [] as string[] })
|
const [createForm, setCreateForm] = useState<CreateForm>(emptyCreate)
|
||||||
|
|
||||||
|
const [editForm, setEditForm] = useState<EditForm | null>(null)
|
||||||
|
|
||||||
const [rolesModal, setRolesModal] = useState<User | null>(null)
|
const [rolesModal, setRolesModal] = useState<User | null>(null)
|
||||||
const [roleSelection, setRoleSelection] = useState<string[]>([])
|
const [roleSelection, setRoleSelection] = useState<string[]>([])
|
||||||
@ -37,15 +58,46 @@ export function UsersPage() {
|
|||||||
(await api.get<Paged<User>>('/users', { params: { page, pageSize: 20, search: search || undefined } })).data,
|
(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({
|
const createMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await api.post('/users', createForm)
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['users'] })
|
qc.invalidateQueries({ queryKey: ['users'] })
|
||||||
toast.success('Đã tạo user')
|
toast.success('Đã tạo user')
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setCreateForm({ email: '', fullName: '', password: '', roles: [] })
|
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)),
|
onError: err => toast.error(getErrorMessage(err)),
|
||||||
})
|
})
|
||||||
@ -86,7 +138,11 @@ export function UsersPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const toggleActiveMut = useMutation({
|
const toggleActiveMut = useMutation({
|
||||||
mutationFn: (u: User) => api.put(`/users/${u.id}`, { id: u.id, fullName: u.fullName, isActive: !u.isActive }),
|
mutationFn: (u: User) =>
|
||||||
|
api.put(`/users/${u.id}`, {
|
||||||
|
id: u.id, fullName: u.fullName, isActive: !u.isActive,
|
||||||
|
departmentId: u.departmentId, position: u.position,
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['users'] })
|
qc.invalidateQueries({ queryKey: ['users'] })
|
||||||
toast.success('Đã cập nhật trạng thái')
|
toast.success('Đã cập nhật trạng thái')
|
||||||
@ -98,11 +154,16 @@ export function UsersPage() {
|
|||||||
setRolesModal(u)
|
setRolesModal(u)
|
||||||
setRoleSelection([...u.roles])
|
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) {
|
function toggleRole(r: string) {
|
||||||
setRoleSelection(sel => (sel.includes(r) ? sel.filter(x => x !== r) : [...sel, r]))
|
setRoleSelection(sel => (sel.includes(r) ? sel.filter(x => x !== r) : [...sel, r]))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCreateRole(r: string) {
|
function toggleCreateRole(r: string) {
|
||||||
setCreateForm(f => ({
|
setCreateForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
@ -113,6 +174,17 @@ export function UsersPage() {
|
|||||||
const columns: Column<User>[] = [
|
const columns: Column<User>[] = [
|
||||||
{ key: 'email', header: 'Email', render: u => <span className="font-mono text-xs">{u.email}</span> },
|
{ 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: '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',
|
key: 'roles',
|
||||||
header: 'Vai trò',
|
header: 'Vai trò',
|
||||||
@ -120,8 +192,12 @@ export function UsersPage() {
|
|||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{u.roles.length === 0 && <span className="text-xs text-slate-400">—</span>}
|
{u.roles.length === 0 && <span className="text-xs text-slate-400">—</span>}
|
||||||
{u.roles.map(r => (
|
{u.roles.map(r => (
|
||||||
<span key={r} className="rounded bg-brand-50 px-1.5 py-0.5 text-xs text-brand-700">
|
<span
|
||||||
{RoleLabel[r] ?? r}
|
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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -130,39 +206,37 @@ export function UsersPage() {
|
|||||||
{
|
{
|
||||||
key: 'isActive',
|
key: 'isActive',
|
||||||
header: 'Active',
|
header: 'Active',
|
||||||
width: 'w-24',
|
width: 'w-20',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: u =>
|
render: u =>
|
||||||
u.isActive ? (
|
u.isActive ? <CheckCircle2 className="mx-auto h-4 w-4 text-emerald-600" /> : <XCircle className="mx-auto h-4 w-4 text-slate-400" />,
|
||||||
<CheckCircle2 className="mx-auto h-4 w-4 text-emerald-600" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="mx-auto h-4 w-4 text-slate-400" />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'isLocked',
|
key: 'isLocked',
|
||||||
header: 'Locked',
|
header: 'Locked',
|
||||||
width: 'w-24',
|
width: 'w-20',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: u =>
|
render: u =>
|
||||||
u.isLocked ? (
|
u.isLocked ? (
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-red-600">
|
<span className="inline-flex items-center gap-1 text-xs text-red-600">
|
||||||
<KeyRound className="h-3.5 w-3.5" />
|
<KeyRound className="h-3.5 w-3.5" />
|
||||||
Locked
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-slate-400">—</span>
|
<span className="text-xs text-slate-400">—</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-28', render: u => fmtDate(u.createdAt) },
|
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: 'w-52',
|
width: 'w-56',
|
||||||
render: u => (
|
render: u => (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<PermissionGuard menuKey={MenuKeys.Users} action="Update">
|
<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">
|
<Button size="sm" variant="ghost" onClick={() => openRoles(u)} title="Gán role">
|
||||||
<Shield className="h-3.5 w-3.5" />
|
<Shield className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -192,7 +266,7 @@ export function UsersPage() {
|
|||||||
Người dùng
|
Người dùng
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description="Tạo user + gán role để test quyền với non-admin."
|
description="Tạo user + gán phòng ban + gán role để test quyền với non-admin."
|
||||||
actions={
|
actions={
|
||||||
<PermissionGuard menuKey={MenuKeys.Users} action="Create">
|
<PermissionGuard menuKey={MenuKeys.Users} action="Create">
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
@ -231,6 +305,7 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form className="space-y-4" onSubmit={e => { e.preventDefault(); createMut.mutate() }}>
|
<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">
|
<div className="space-y-1.5">
|
||||||
<Label>Email *</Label>
|
<Label>Email *</Label>
|
||||||
<Input type="email" value={createForm.email} onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))} required />
|
<Input type="email" value={createForm.email} onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))} required />
|
||||||
@ -240,12 +315,30 @@ export function UsersPage() {
|
|||||||
<Input value={createForm.fullName} onChange={e => setCreateForm(f => ({ ...f, fullName: e.target.value }))} required />
|
<Input value={createForm.fullName} onChange={e => setCreateForm(f => ({ ...f, fullName: e.target.value }))} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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>
|
<Label>Password *</Label>
|
||||||
<Input type="password" value={createForm.password} onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))} required minLength={8} />
|
<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 ký tự + chữ hoa + thường + số + ký tự đặc biệt</div>
|
<div className="text-xs text-slate-500">Tối thiểu 8 ký tự + chữ hoa + thường + số + ký tự đặc biệt</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Roles</Label>
|
<Label>Vai trò (Mã — Tên)</Label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{AVAILABLE_ROLES.map(r => (
|
{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">
|
<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">
|
||||||
@ -255,7 +348,8 @@ export function UsersPage() {
|
|||||||
checked={createForm.roles.includes(r)}
|
checked={createForm.roles.includes(r)}
|
||||||
onChange={() => toggleCreateRole(r)}
|
onChange={() => toggleCreateRole(r)}
|
||||||
/>
|
/>
|
||||||
{RoleLabel[r]}
|
<span className="font-semibold text-slate-700">{RoleShortName[r]}</span>
|
||||||
|
<span className="text-slate-500">— {RoleLabel[r]}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -263,11 +357,73 @@ export function UsersPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</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 */}
|
{/* Assign roles */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={!!rolesModal}
|
open={!!rolesModal}
|
||||||
onClose={() => setRolesModal(null)}
|
onClose={() => setRolesModal(null)}
|
||||||
title={`Gán role cho ${rolesModal?.fullName}`}
|
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"
|
size="md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@ -287,7 +443,8 @@ export function UsersPage() {
|
|||||||
checked={roleSelection.includes(r)}
|
checked={roleSelection.includes(r)}
|
||||||
onChange={() => toggleRole(r)}
|
onChange={() => toggleRole(r)}
|
||||||
/>
|
/>
|
||||||
{RoleLabel[r]}
|
<span className="font-semibold text-slate-700">{RoleShortName[r]}</span>
|
||||||
|
<span className="text-slate-500">— {RoleLabel[r]}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export type MenuItem = {
|
|||||||
export type Role = {
|
export type Role = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
shortName: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,9 @@ export type User = {
|
|||||||
isLocked: boolean
|
isLocked: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
departmentId: string | null
|
||||||
|
departmentName: string | null
|
||||||
|
position: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateUserInput = {
|
export type CreateUserInput = {
|
||||||
@ -13,9 +16,12 @@ export type CreateUserInput = {
|
|||||||
fullName: string
|
fullName: string
|
||||||
password: string
|
password: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
departmentId: string | null
|
||||||
|
position: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12 role seed trong BE (AppRoles.cs)
|
// 12 role seed trong BE (AppRoles.cs). Mã + Tên đầy đủ tiếng Việt khớp
|
||||||
|
// với BE SeedRolesAsync (Role.ShortName + Role.Description).
|
||||||
export const AVAILABLE_ROLES = [
|
export const AVAILABLE_ROLES = [
|
||||||
'Admin',
|
'Admin',
|
||||||
'Drafter',
|
'Drafter',
|
||||||
@ -31,17 +37,42 @@ export const AVAILABLE_ROLES = [
|
|||||||
'HrAdmin',
|
'HrAdmin',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
// Mã viết tắt (Vietnamese abbreviation) — dùng khi không gian hẹp (badge, chip)
|
||||||
|
export const RoleShortName: Record<string, string> = {
|
||||||
|
Admin: 'QTV',
|
||||||
|
Drafter: 'NV.PB',
|
||||||
|
DeptManager: 'TPB',
|
||||||
|
ProjectManager: 'PM',
|
||||||
|
Procurement: 'PRO',
|
||||||
|
CostControl: 'CCM',
|
||||||
|
Finance: 'FIN',
|
||||||
|
Accounting: 'ACT',
|
||||||
|
Equipment: 'EQU',
|
||||||
|
Director: 'BOD',
|
||||||
|
AuthorizedSigner: 'NĐUQ',
|
||||||
|
HrAdmin: 'HRA',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tên đầy đủ tiếng Việt — dùng khi rộng rãi (form, list)
|
||||||
export const RoleLabel: Record<string, string> = {
|
export const RoleLabel: Record<string, string> = {
|
||||||
Admin: 'Quản trị',
|
Admin: 'Quản trị viên hệ thống',
|
||||||
Drafter: 'Người soạn',
|
Drafter: 'Nhân viên phòng ban (soạn HĐ)',
|
||||||
DeptManager: 'Trưởng phòng ban',
|
DeptManager: 'Trưởng phòng ban',
|
||||||
ProjectManager: 'Giám đốc dự án',
|
ProjectManager: 'Giám đốc dự án',
|
||||||
Procurement: 'Cung ứng',
|
Procurement: 'Phòng Cung ứng',
|
||||||
CostControl: 'Kiểm soát chi phí',
|
CostControl: 'Phòng Kiểm soát chi phí',
|
||||||
Finance: 'Tài chính',
|
Finance: 'Phòng Tài chính',
|
||||||
Accounting: 'Kế toán',
|
Accounting: 'Phòng Kế toán',
|
||||||
Equipment: 'Thiết bị',
|
Equipment: 'Phòng Thiết bị',
|
||||||
Director: 'Ban Giám đốc',
|
Director: 'Ban Giám đốc',
|
||||||
AuthorizedSigner: 'Người ủy quyền ký',
|
AuthorizedSigner: 'Người được Ủy quyền ký HĐ',
|
||||||
HrAdmin: 'Nhân sự / Đóng dấu',
|
HrAdmin: 'Phòng Nhân sự - Hành chính',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined display: "BOD — Ban Giám đốc"
|
||||||
|
export function roleDisplayName(roleName: string): string {
|
||||||
|
const short = RoleShortName[roleName]
|
||||||
|
const full = RoleLabel[roleName]
|
||||||
|
if (short && full) return `${short} — ${full}`
|
||||||
|
return full ?? roleName
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user