[CLAUDE] FE-Admin: PermissionsPage 3-panel layout
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m33s

Redesign theo yêu cầu user: 3 panel vertical đồng thời trên cùng 1
màn hình (không modal/dialog popup).

Layout grid lg:grid-cols-[280px_1fr_300px]:

Panel 1 — Vai trò (trái, 280px):
  Danh sách roles click-to-select với active highlight (brand-50 bg +
  ring-brand-200 + check icon). Đếm số roles ở header.

Panel 2 — Quyền theo menu (giữa, flex):
  Tìm menu inline header + sticky thead. Click vai trò → lọc menu
  instant. Column toggle header (tick toàn cột) + per-cell checkbox.
  Hover brand-tinted. Menu key hiện mono nhỏ dưới label.

Panel 3 — Tổng quan (phải, 300px):
  Vai trò đang chọn + số quyền (progress bar brand) + chi tiết từng
  CRUD (Xem/Tạo/Sửa/Xóa) với badge color-coded (slate/emerald/amber/
  red) + count "X / Y menus" + tip helper cuối.

Bỏ dialog select + 3-col grid filter ở đầu (thay bằng 3 panel), giữ
logic mutation/toggle/column nguyên.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-22 10:02:25 +07:00
parent f216169039
commit 91b2da147f

View File

@ -1,21 +1,20 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Search, Shield, Check } from 'lucide-react' import { Search, Shield, Check, Users, KeyRound, BarChart3 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { MenuItem, Permission, Role } from '@/types/menu' import type { MenuItem, Permission, Role } from '@/types/menu'
type CrudKey = 'canRead' | 'canCreate' | 'canUpdate' | 'canDelete' type CrudKey = 'canRead' | 'canCreate' | 'canUpdate' | 'canDelete'
const CRUD_COLS: { key: CrudKey; label: string; short: string }[] = [ const CRUD_COLS: { key: CrudKey; label: string; short: string; tone: string }[] = [
{ key: 'canRead', label: 'Xem', short: 'R' }, { key: 'canRead', label: 'Xem', short: 'R', tone: 'bg-slate-100 text-slate-700' },
{ key: 'canCreate', label: 'Tạo', short: 'C' }, { key: 'canCreate', label: 'Tạo', short: 'C', tone: 'bg-emerald-100 text-emerald-700' },
{ key: 'canUpdate', label: 'Sửa', short: 'U' }, { key: 'canUpdate', label: 'Sửa', short: 'U', tone: 'bg-amber-100 text-amber-700' },
{ key: 'canDelete', label: 'Xóa', short: 'D' }, { key: 'canDelete', label: 'Xóa', short: 'D', tone: 'bg-red-100 text-red-700' },
] ]
export function PermissionsPage() { export function PermissionsPage() {
@ -43,9 +42,7 @@ export function PermissionsPage() {
mutationFn: async (p: { menuKey: string; canRead: boolean; canCreate: boolean; canUpdate: boolean; canDelete: boolean }) => { mutationFn: async (p: { menuKey: string; canRead: boolean; canCreate: boolean; canUpdate: boolean; canDelete: boolean }) => {
await api.put('/permissions', { roleId, ...p }) await api.put('/permissions', { roleId, ...p })
}, },
onSuccess: () => { onSuccess: () => qc.invalidateQueries({ queryKey: ['permissions', roleId] }),
qc.invalidateQueries({ queryKey: ['permissions', roleId] })
},
onError: err => toast.error(getErrorMessage(err)), onError: err => toast.error(getErrorMessage(err)),
}) })
@ -63,17 +60,19 @@ export function PermissionsPage() {
}, [menus.data, search]) }, [menus.data, search])
const stats = useMemo(() => { const stats = useMemo(() => {
const total = (menus.data?.length ?? 0) * 4 const totalMenus = menus.data?.length ?? 0
const total = totalMenus * 4
const breakdown = { canRead: 0, canCreate: 0, canUpdate: 0, canDelete: 0 }
let granted = 0 let granted = 0
for (const m of menus.data ?? []) { for (const m of menus.data ?? []) {
const p = permMap.get(m.key) const p = permMap.get(m.key)
if (!p) continue if (!p) continue
if (p.canRead) granted++ if (p.canRead) { granted++; breakdown.canRead++ }
if (p.canCreate) granted++ if (p.canCreate) { granted++; breakdown.canCreate++ }
if (p.canUpdate) granted++ if (p.canUpdate) { granted++; breakdown.canUpdate++ }
if (p.canDelete) granted++ if (p.canDelete) { granted++; breakdown.canDelete++ }
} }
return { granted, total } return { granted, total, totalMenus, breakdown }
}, [menus.data, permMap]) }, [menus.data, permMap])
function currentFlags(menuKey: string) { function currentFlags(menuKey: string) {
@ -88,8 +87,7 @@ export function PermissionsPage() {
function toggle(menuKey: string, field: CrudKey) { function toggle(menuKey: string, field: CrudKey) {
const flags = currentFlags(menuKey) const flags = currentFlags(menuKey)
const next = { ...flags, [field]: !flags[field] } upsert.mutate({ menuKey, ...flags, [field]: !flags[field] })
upsert.mutate({ menuKey, ...next })
} }
function columnAllChecked(field: CrudKey) { function columnAllChecked(field: CrudKey) {
@ -113,65 +111,78 @@ export function PermissionsPage() {
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
title="Ma trận phân quyền" title="Ma trận phân quyền"
description="Tick để gán quyền Xem / Tạo / Sửa / Xóa theo vai trò. Thay đổi lưu tự động." 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."
/> />
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-3"> {/* 3-column layout. Heights match so panels align. */}
<div> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[280px_1fr_300px]">
<label className="mb-1 block text-xs font-medium text-slate-600">Vai trò</label> {/* ===== Panel 1: Role list ===== */}
<Select value={roleId} onChange={e => setRoleId(e.target.value)}> <section className="flex flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<option value="">-- Chọn vai trò --</option> <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 => ( {roles.data?.map(r => (
<option key={r.id} value={r.id}> <button
{r.name} key={r.id}
</option> onClick={() => setRoleId(r.id)}
className={cn(
'flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition',
roleId === r.id
? 'bg-brand-50 text-brand-700 font-medium ring-1 ring-brand-200'
: 'text-slate-600 hover:bg-slate-50',
)}
>
<span className="truncate">{r.name}</span>
{roleId === r.id && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))} ))}
</Select>
</div> </div>
<div> </section>
<label className="mb-1 block text-xs font-medium text-slate-600">Tìm menu</label>
{/* ===== 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">
{selectedRole.name}
</span>
)}
<div className="ml-auto w-56">
<div className="relative"> <div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" /> <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 <Input
placeholder="Tên hoặc key menu…" placeholder="Tìm menu…"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="pl-8" className="h-8 pl-7 text-xs"
disabled={!roleId} disabled={!roleId}
/> />
</div> </div>
</div> </div>
{roleId && ( </header>
<div className="flex items-end">
<div className="rounded-md border border-slate-200 bg-white px-4 py-2 text-xs">
<div className="flex items-center gap-1.5 text-slate-500">
<Shield className="h-3.5 w-3.5" />
{selectedRole?.name}
</div>
<div className="mt-0.5 font-mono text-slate-900">
{stats.granted}
<span className="text-slate-400"> / {stats.total} quyền</span>
</div>
</div>
</div>
)}
</div>
{!roleId ? ( {!roleId ? (
<EmptyState <div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-slate-400">
icon={Shield} <div>
title="Chọn một vai trò để bắt đầu" <Shield className="mx-auto mb-2 h-8 w-8 text-slate-300" />
description="Ma trận phân quyền sẽ hiện theo vai trò đã chọn." <div>Chọn vai trò panel 1 đ bắt đu</div>
/> </div>
</div>
) : ( ) : (
<div className="overflow-auto rounded-md border border-slate-200 bg-white"> <div className="flex-1 overflow-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-700"> <thead className="sticky top-0 bg-slate-50/80 text-slate-600 backdrop-blur">
<tr> <tr className="border-b border-slate-200">
<th className="px-3 py-2 text-left font-medium"> <th className="px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-wider">
Menu Menu
{search && ( {search && (
<span className="ml-2 text-xs font-normal text-slate-400"> <span className="ml-2 font-normal normal-case text-slate-400">
({filteredMenus.length} kết quả) ({filteredMenus.length} kết quả)
</span> </span>
)} )}
@ -179,14 +190,19 @@ export function PermissionsPage() {
{CRUD_COLS.map(c => { {CRUD_COLS.map(c => {
const allChecked = columnAllChecked(c.key) const allChecked = columnAllChecked(c.key)
return ( return (
<th key={c.key} className="w-24 px-3 py-2 text-center font-medium"> <th key={c.key} className="w-20 px-2 py-2 text-center">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<span>{c.label}</span> <span className="text-[11px] font-semibold uppercase tracking-wider">{c.label}</span>
<button <button
onClick={() => toggleColumn(c.key)} onClick={() => toggleColumn(c.key)}
title={allChecked ? 'Bỏ tick toàn cột' : 'Tick toàn cột'} title={allChecked ? 'Bỏ tick toàn cột' : 'Tick toàn cột'}
disabled={upsert.isPending || filteredMenus.length === 0} disabled={upsert.isPending || filteredMenus.length === 0}
className={`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'}`} 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" />} {allChecked && <Check className="h-3 w-3" />}
</button> </button>
@ -199,7 +215,7 @@ export function PermissionsPage() {
<tbody> <tbody>
{filteredMenus.length === 0 && ( {filteredMenus.length === 0 && (
<tr> <tr>
<td colSpan={5} className="px-3 py-10 text-center text-xs text-slate-400"> <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. Không menu khớp với từ khóa.
</td> </td>
</tr> </tr>
@ -208,13 +224,13 @@ export function PermissionsPage() {
const flags = currentFlags(m.key) const flags = currentFlags(m.key)
const depth = m.parentKey ? 1 : 0 const depth = m.parentKey ? 1 : 0
return ( return (
<tr key={m.key} className="border-t border-slate-100 hover:bg-slate-50/50"> <tr key={m.key} className="border-t border-slate-100 hover:bg-brand-50/30">
<td className="px-3 py-2" style={{ paddingLeft: `${0.75 + depth * 1.5}rem` }}> <td className="px-4 py-2" style={{ paddingLeft: `${1 + depth * 1.25}rem` }}>
<span className="font-medium text-slate-800">{m.label}</span> <div className="font-medium text-slate-800">{m.label}</div>
<span className="ml-2 font-mono text-xs text-slate-400">{m.key}</span> <div className="font-mono text-[10px] text-slate-400">{m.key}</div>
</td> </td>
{CRUD_COLS.map(c => ( {CRUD_COLS.map(c => (
<td key={c.key} className="px-3 py-2 text-center"> <td key={c.key} className="px-2 py-2 text-center">
<input <input
type="checkbox" type="checkbox"
className="h-4 w-4 cursor-pointer accent-brand-600" className="h-4 w-4 cursor-pointer accent-brand-600"
@ -231,6 +247,64 @@ export function PermissionsPage() {
</table> </table>
</div> </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> </div>
) )
} }