diff --git a/fe-admin/src/pages/system/PermissionsPage.tsx b/fe-admin/src/pages/system/PermissionsPage.tsx index ec60cee..2f66d8b 100644 --- a/fe-admin/src/pages/system/PermissionsPage.tsx +++ b/fe-admin/src/pages/system/PermissionsPage.tsx @@ -1,21 +1,20 @@ import { useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { EmptyState } from '@/components/EmptyState' import { Input } from '@/components/ui/Input' -import { Select } from '@/components/ui/Select' 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 }[] = [ - { key: 'canRead', label: 'Xem', short: 'R' }, - { key: 'canCreate', label: 'Tạo', short: 'C' }, - { key: 'canUpdate', label: 'Sửa', short: 'U' }, - { key: 'canDelete', label: 'Xóa', short: 'D' }, +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() { @@ -43,9 +42,7 @@ export function PermissionsPage() { 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] }) - }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['permissions', roleId] }), onError: err => toast.error(getErrorMessage(err)), }) @@ -63,17 +60,19 @@ export function PermissionsPage() { }, [menus.data, search]) 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 for (const m of menus.data ?? []) { const p = permMap.get(m.key) if (!p) continue - if (p.canRead) granted++ - if (p.canCreate) granted++ - if (p.canUpdate) granted++ - if (p.canDelete) granted++ + 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 } + return { granted, total, totalMenus, breakdown } }, [menus.data, permMap]) function currentFlags(menuKey: string) { @@ -88,8 +87,7 @@ export function PermissionsPage() { function toggle(menuKey: string, field: CrudKey) { const flags = currentFlags(menuKey) - const next = { ...flags, [field]: !flags[field] } - upsert.mutate({ menuKey, ...next }) + upsert.mutate({ menuKey, ...flags, [field]: !flags[field] }) } function columnAllChecked(field: CrudKey) { @@ -113,124 +111,200 @@ export function PermissionsPage() {
-
-
- - -
-
- -
- - setSearch(e.target.value)} - className="pl-8" - disabled={!roleId} - />
-
- {roleId && ( -
-
-
- - {selectedRole?.name} -
-
- {stats.granted} - / {stats.total} quyền + + + {/* ===== Panel 2: Menu × CRUD matrix ===== */} +
+
+ +

2. Quyền theo menu

+ {selectedRole && ( + + {selectedRole.name} + + )} +
+
+ + setSearch(e.target.value)} + className="h-8 pl-7 text-xs" + disabled={!roleId} + />
-
- )} -
+ - {!roleId ? ( - - ) : ( -
- - - - - {CRUD_COLS.map(c => { - const allChecked = columnAllChecked(c.key) - return ( - + + {filteredMenus.length === 0 && ( + + + + )} + {filteredMenus.map(m => { + const flags = currentFlags(m.key) + const depth = m.parentKey ? 1 : 0 + return ( + + + {CRUD_COLS.map(c => ( + + ))} + + ) + })} + +
- Menu - {search && ( - - ({filteredMenus.length} kết quả) - - )} - -
- {c.label} - -
+ {!roleId ? ( +
+
+ +
Chọn vai trò ở panel 1 để bắt đầu
+
+
+ ) : ( +
+ + + + - ) - })} - - - - {filteredMenus.length === 0 && ( - - - - )} - {filteredMenus.map(m => { - const flags = currentFlags(m.key) - const depth = m.parentKey ? 1 : 0 - return ( - - - {CRUD_COLS.map(c => ( - - ))} + {CRUD_COLS.map(c => { + const allChecked = columnAllChecked(c.key) + return ( + + ) + })} - ) - })} - -
+ Menu + {search && ( + + ({filteredMenus.length} kết quả) + + )}
- Không có menu khớp với từ khóa. -
- {m.label} - {m.key} - - toggle(m.key, c.key)} - /> - +
+ {c.label} + +
+
-
- )} +
+ Không có menu khớp với từ khóa. +
+
{m.label}
+
{m.key}
+
+ toggle(m.key, c.key)} + /> +
+
+ )} + + + {/* ===== Panel 3: Stats / summary ===== */} +
+
+ +

3. Tổng quan

+
+
+ {!roleId ? ( +
Chưa có vai trò được chọn.
+ ) : ( + <> +
+
Vai trò
+
+ + {selectedRole?.name} +
+
+ +
+
Quyền đã cấp
+
+ {stats.granted} + / {stats.total} +
+
+
0 ? (stats.granted / stats.total) * 100 : 0}%` }} + /> +
+
+ +
+
Chi tiết CRUD
+ {CRUD_COLS.map(c => ( +
+ + {c.label} + + + {stats.breakdown[c.key]} + / {stats.totalMenus} + +
+ ))} +
+ +
+ Tip: click header ô tick ở panel 2 để tick/untick toàn cột. Thay đổi tự lưu. +
+ + )} +
+
+
) }