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>
317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { toast } from 'sonner'
|
||
import { Search, Shield, Check, Users, KeyRound, BarChart3 } from 'lucide-react'
|
||
import { PageHeader } from '@/components/PageHeader'
|
||
import { Input } from '@/components/ui/Input'
|
||
import { api } from '@/lib/api'
|
||
import { getErrorMessage } from '@/lib/apiError'
|
||
import { cn } from '@/lib/cn'
|
||
import type { MenuItem, Permission, Role } from '@/types/menu'
|
||
|
||
type CrudKey = 'canRead' | 'canCreate' | 'canUpdate' | 'canDelete'
|
||
const CRUD_COLS: { key: CrudKey; label: string; short: string; tone: string }[] = [
|
||
{ key: 'canRead', label: 'Xem', short: 'R', tone: 'bg-slate-100 text-slate-700' },
|
||
{ key: 'canCreate', label: 'Tạo', short: 'C', tone: 'bg-emerald-100 text-emerald-700' },
|
||
{ key: 'canUpdate', label: 'Sửa', short: 'U', tone: 'bg-amber-100 text-amber-700' },
|
||
{ key: 'canDelete', label: 'Xóa', short: 'D', tone: 'bg-red-100 text-red-700' },
|
||
]
|
||
|
||
export function PermissionsPage() {
|
||
const qc = useQueryClient()
|
||
const [roleId, setRoleId] = useState<string>('')
|
||
const [search, setSearch] = useState('')
|
||
|
||
const roles = useQuery({
|
||
queryKey: ['roles'],
|
||
queryFn: async () => (await api.get<Role[]>('/roles')).data,
|
||
})
|
||
|
||
const menus = useQuery({
|
||
queryKey: ['menus', 'all'],
|
||
queryFn: async () => (await api.get<MenuItem[]>('/menus')).data,
|
||
})
|
||
|
||
const perms = useQuery({
|
||
queryKey: ['permissions', roleId],
|
||
queryFn: async () => (await api.get<Permission[]>(`/permissions/by-role/${roleId}`)).data,
|
||
enabled: !!roleId,
|
||
})
|
||
|
||
const upsert = useMutation({
|
||
mutationFn: async (p: { menuKey: string; canRead: boolean; canCreate: boolean; canUpdate: boolean; canDelete: boolean }) => {
|
||
await api.put('/permissions', { roleId, ...p })
|
||
},
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ['permissions', roleId] }),
|
||
onError: err => toast.error(getErrorMessage(err)),
|
||
})
|
||
|
||
const permMap = useMemo(() => {
|
||
const map = new Map<string, Permission>()
|
||
for (const p of perms.data ?? []) map.set(p.menuKey, p)
|
||
return map
|
||
}, [perms.data])
|
||
|
||
const filteredMenus = useMemo(() => {
|
||
const all = menus.data ?? []
|
||
if (!search.trim()) return all
|
||
const q = search.toLowerCase()
|
||
return all.filter(m => m.label.toLowerCase().includes(q) || m.key.toLowerCase().includes(q))
|
||
}, [menus.data, search])
|
||
|
||
const stats = useMemo(() => {
|
||
const totalMenus = menus.data?.length ?? 0
|
||
const total = totalMenus * 4
|
||
const breakdown = { canRead: 0, canCreate: 0, canUpdate: 0, canDelete: 0 }
|
||
let granted = 0
|
||
for (const m of menus.data ?? []) {
|
||
const p = permMap.get(m.key)
|
||
if (!p) continue
|
||
if (p.canRead) { granted++; breakdown.canRead++ }
|
||
if (p.canCreate) { granted++; breakdown.canCreate++ }
|
||
if (p.canUpdate) { granted++; breakdown.canUpdate++ }
|
||
if (p.canDelete) { granted++; breakdown.canDelete++ }
|
||
}
|
||
return { granted, total, totalMenus, breakdown }
|
||
}, [menus.data, permMap])
|
||
|
||
function currentFlags(menuKey: string) {
|
||
const p = permMap.get(menuKey)
|
||
return {
|
||
canRead: p?.canRead ?? false,
|
||
canCreate: p?.canCreate ?? false,
|
||
canUpdate: p?.canUpdate ?? false,
|
||
canDelete: p?.canDelete ?? false,
|
||
}
|
||
}
|
||
|
||
function toggle(menuKey: string, field: CrudKey) {
|
||
const flags = currentFlags(menuKey)
|
||
upsert.mutate({ menuKey, ...flags, [field]: !flags[field] })
|
||
}
|
||
|
||
function columnAllChecked(field: CrudKey) {
|
||
return filteredMenus.length > 0 && filteredMenus.every(m => currentFlags(m.key)[field])
|
||
}
|
||
|
||
function toggleColumn(field: CrudKey) {
|
||
const allChecked = columnAllChecked(field)
|
||
const nextVal = !allChecked
|
||
for (const m of filteredMenus) {
|
||
const flags = currentFlags(m.key)
|
||
if (flags[field] !== nextVal) {
|
||
upsert.mutate({ menuKey: m.key, ...flags, [field]: nextVal })
|
||
}
|
||
}
|
||
}
|
||
|
||
const selectedRole = roles.data?.find(r => r.id === roleId)
|
||
|
||
return (
|
||
<div className="p-6">
|
||
<PageHeader
|
||
title="Ma trận phân quyền"
|
||
description="3 panel — chọn vai trò (Panel 1), tick quyền CRUD cho từng menu (Panel 2), xem tổng quan (Panel 3). Thay đổi lưu tự động."
|
||
/>
|
||
|
||
{/* 3-column layout. Heights match so panels align. */}
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr_300px]">
|
||
{/* ===== Panel 1: Role list ===== */}
|
||
<section className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||
<header className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||
<Users className="h-4 w-4 text-brand-600" />
|
||
<h2 className="text-sm font-semibold text-slate-700">1. Vai trò</h2>
|
||
<span className="ml-auto text-[11px] text-slate-400">{roles.data?.length ?? 0}</span>
|
||
</header>
|
||
<div className="flex-1 overflow-y-auto p-2">
|
||
{roles.isLoading && <div className="p-4 text-xs text-slate-400">Đang tải…</div>}
|
||
{roles.data?.map(r => (
|
||
<button
|
||
key={r.id}
|
||
onClick={() => setRoleId(r.id)}
|
||
className={cn(
|
||
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left transition',
|
||
roleId === r.id
|
||
? 'bg-brand-50 text-brand-700 font-medium ring-1 ring-brand-200'
|
||
: 'text-slate-600 hover:bg-slate-50',
|
||
)}
|
||
title={r.description ?? r.name}
|
||
>
|
||
<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" />}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ===== Panel 2: Menu × CRUD matrix ===== */}
|
||
<section className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||
<header className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||
<KeyRound className="h-4 w-4 text-brand-600" />
|
||
<h2 className="text-sm font-semibold text-slate-700">2. Quyền theo menu</h2>
|
||
{selectedRole && (
|
||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700" title={selectedRole.description ?? ''}>
|
||
{selectedRole.shortName ?? selectedRole.name}
|
||
</span>
|
||
)}
|
||
<div className="ml-auto w-56">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||
<Input
|
||
placeholder="Tìm menu…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
className="h-8 pl-7 text-xs"
|
||
disabled={!roleId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{!roleId ? (
|
||
<div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-slate-400">
|
||
<div>
|
||
<Shield className="mx-auto mb-2 h-8 w-8 text-slate-300" />
|
||
<div>Chọn vai trò ở panel 1 để bắt đầu</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 overflow-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="sticky top-0 bg-slate-50/80 text-slate-600 backdrop-blur">
|
||
<tr className="border-b border-slate-200">
|
||
<th className="px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-wider">
|
||
Menu
|
||
{search && (
|
||
<span className="ml-2 font-normal normal-case text-slate-400">
|
||
({filteredMenus.length} kết quả)
|
||
</span>
|
||
)}
|
||
</th>
|
||
{CRUD_COLS.map(c => {
|
||
const allChecked = columnAllChecked(c.key)
|
||
return (
|
||
<th key={c.key} className="w-20 px-2 py-2 text-center">
|
||
<div className="flex flex-col items-center gap-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider">{c.label}</span>
|
||
<button
|
||
onClick={() => toggleColumn(c.key)}
|
||
title={allChecked ? 'Bỏ tick toàn cột' : 'Tick toàn cột'}
|
||
disabled={upsert.isPending || filteredMenus.length === 0}
|
||
className={cn(
|
||
'flex h-5 w-5 items-center justify-center rounded border transition',
|
||
allChecked
|
||
? 'border-brand-600 bg-brand-600 text-white'
|
||
: 'border-slate-300 bg-white text-slate-400 hover:border-brand-500',
|
||
)}
|
||
>
|
||
{allChecked && <Check className="h-3 w-3" />}
|
||
</button>
|
||
</div>
|
||
</th>
|
||
)
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredMenus.length === 0 && (
|
||
<tr>
|
||
<td colSpan={5} className="px-4 py-10 text-center text-xs text-slate-400">
|
||
Không có menu khớp với từ khóa.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{filteredMenus.map(m => {
|
||
const flags = currentFlags(m.key)
|
||
const depth = m.parentKey ? 1 : 0
|
||
return (
|
||
<tr key={m.key} className="border-t border-slate-100 hover:bg-brand-50/30">
|
||
<td className="px-4 py-2" style={{ paddingLeft: `${1 + depth * 1.25}rem` }}>
|
||
<div className="font-medium text-slate-800">{m.label}</div>
|
||
<div className="font-mono text-[10px] text-slate-400">{m.key}</div>
|
||
</td>
|
||
{CRUD_COLS.map(c => (
|
||
<td key={c.key} className="px-2 py-2 text-center">
|
||
<input
|
||
type="checkbox"
|
||
className="h-4 w-4 cursor-pointer accent-brand-600"
|
||
checked={flags[c.key]}
|
||
disabled={upsert.isPending}
|
||
onChange={() => toggle(m.key, c.key)}
|
||
/>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* ===== Panel 3: Stats / summary ===== */}
|
||
<section className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
|
||
<header className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||
<BarChart3 className="h-4 w-4 text-brand-600" />
|
||
<h2 className="text-sm font-semibold text-slate-700">3. Tổng quan</h2>
|
||
</header>
|
||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||
{!roleId ? (
|
||
<div className="text-center text-xs text-slate-400">Chưa có vai trò được chọn.</div>
|
||
) : (
|
||
<>
|
||
<div>
|
||
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">Vai trò</div>
|
||
<div className="mt-1 flex items-center gap-2 text-sm font-semibold text-slate-800">
|
||
<Shield className="h-3.5 w-3.5 text-brand-600" />
|
||
{selectedRole?.name}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">Quyền đã cấp</div>
|
||
<div className="mt-1 flex items-baseline gap-1">
|
||
<span className="text-3xl font-bold text-brand-600 tabular-nums">{stats.granted}</span>
|
||
<span className="text-xs text-slate-400">/ {stats.total}</span>
|
||
</div>
|
||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||
<div
|
||
className="h-full rounded-full bg-brand-500 transition-all"
|
||
style={{ width: `${stats.total > 0 ? (stats.granted / stats.total) * 100 : 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">Chi tiết CRUD</div>
|
||
{CRUD_COLS.map(c => (
|
||
<div key={c.key} className="flex items-center justify-between rounded-md border border-slate-100 px-2.5 py-1.5">
|
||
<span className={cn('rounded px-1.5 py-0.5 text-[11px] font-medium', c.tone)}>
|
||
{c.label}
|
||
</span>
|
||
<span className="font-mono text-xs text-slate-600">
|
||
{stats.breakdown[c.key]}
|
||
<span className="text-slate-400"> / {stats.totalMenus}</span>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="rounded-md border border-slate-100 bg-slate-50 p-3 text-[11px] leading-relaxed text-slate-500">
|
||
<strong>Tip:</strong> click header ô tick ở panel 2 để tick/untick toàn cột. Thay đổi tự lưu.
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|