Files
solution-erp/fe-admin/src/pages/system/PermissionsPage.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

317 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 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>
)
}