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('') const [search, setSearch] = useState('') const roles = useQuery({ queryKey: ['roles'], queryFn: async () => (await api.get('/roles')).data, }) const menus = useQuery({ queryKey: ['menus', 'all'], queryFn: async () => (await api.get('/menus')).data, }) const perms = useQuery({ queryKey: ['permissions', roleId], queryFn: async () => (await api.get(`/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() 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 (
{/* 3-column layout. Heights match so panels align. */}
{/* ===== Panel 1: Role list ===== */}

1. Vai trò

{roles.data?.length ?? 0}
{roles.isLoading &&
Đang tải…
} {roles.data?.map(r => ( ))}
{/* ===== Panel 2: Menu × CRUD matrix ===== */}

2. Quyền theo menu

{selectedRole && ( {selectedRole.shortName ?? selectedRole.name} )}
setSearch(e.target.value)} className="h-8 pl-7 text-xs" disabled={!roleId} />
{!roleId ? (
Chọn vai trò ở panel 1 để bắt đầu
) : (
{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}
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.
)}
) }