import { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Search, Loader2, Grid3X3, Check, X, Shield, Download, Users, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { rolesService } from "@/services/roles.service"; import type { Permission, AuthorityMatrixRole, UserAuthorityMatrixUser } from "@/types/role-permission"; const CATEGORY_COLORS: Record = { exam: "border-violet-400 bg-violet-50 dark:bg-violet-950", user: "border-blue-400 bg-blue-50 dark:bg-blue-950", entity: "border-emerald-400 bg-emerald-50 dark:bg-emerald-950", assignment: "border-amber-400 bg-amber-50 dark:bg-amber-950", classroom: "border-rose-400 bg-rose-50 dark:bg-rose-950", payment: "border-cyan-400 bg-cyan-50 dark:bg-cyan-950", report: "border-orange-400 bg-orange-50 dark:bg-orange-950", }; const CATEGORY_BADGE: Record = { exam: "bg-violet-100 text-violet-700", user: "bg-blue-100 text-blue-700", entity: "bg-emerald-100 text-emerald-700", assignment: "bg-amber-100 text-amber-700", classroom: "bg-rose-100 text-rose-700", payment: "bg-cyan-100 text-cyan-700", report: "bg-orange-100 text-orange-700", }; function initials(name: string) { return name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2); } function filterCategories(categories: Record, query: string) { if (!query) return categories; const q = query.toLowerCase(); const result: Record = {}; for (const [cat, perms] of Object.entries(categories)) { const filtered = perms.filter(p => p.name.toLowerCase().includes(q) || p.code.toLowerCase().includes(q) || cat.toLowerCase().includes(q), ); if (filtered.length > 0) result[cat] = filtered; } return result; } // ── Shared matrix grid component ── function MatrixGrid({ columns, categories, search, renderColumnHeader, onCellClick, pendingCells, }: { columns: T[]; categories: Record; search: string; renderColumnHeader: (col: T) => React.ReactNode; onCellClick?: (colId: number, permId: number) => void; pendingCells: Set; }) { const filtered = useMemo(() => filterCategories(categories, search), [categories, search]); if (columns.length === 0) { return No data available.; } if (Object.keys(filtered).length === 0) { return No permissions match your search.; } return (
{Object.entries(filtered).map(([cat, perms]) => ( {cat} {perms.length} permission{perms.length !== 1 ? "s" : ""} {columns.map(col => ( ))} {perms.map((p, idx) => ( {columns.map(col => { const granted = col.granted_permission_ids.includes(p.id); const cellKey = `${col.id}-${p.id}`; const pending = pendingCells.has(cellKey); return ( ); })} ))}
Permission {renderColumnHeader(col)}
{p.code} {p.name}

{p.name}

{p.description || "No description"}

{granted ? "Granted" : "Not granted"}: {p.code}
))}
); } export default function AuthorityMatrix() { const { toast } = useToast(); const qc = useQueryClient(); const [search, setSearch] = useState(""); const [tab, setTab] = useState("roles"); const [pendingCells, setPendingCells] = useState>(new Set()); const roleMatrixQ = useQuery({ queryKey: ["authority-matrix"], queryFn: rolesService.getAuthorityMatrix }); const userMatrixQ = useQuery({ queryKey: ["user-authority-matrix"], queryFn: rolesService.getUserAuthorityMatrix, enabled: tab === "users" }); const toggleMut = useMutation({ mutationFn: ({ roleId, permId }: { roleId: number; permId: number }) => rolesService.toggleMatrixCell(roleId, permId), onMutate: ({ roleId, permId }) => { setPendingCells(prev => new Set(prev).add(`${roleId}-${permId}`)); }, onSuccess: (result) => { qc.invalidateQueries({ queryKey: ["authority-matrix"] }); qc.invalidateQueries({ queryKey: ["user-authority-matrix"] }); setPendingCells(prev => { const s = new Set(prev); s.delete(`${result.role_id}-${result.permission_id}`); return s; }); }, onError: (_err, { roleId, permId }) => { setPendingCells(prev => { const s = new Set(prev); s.delete(`${roleId}-${permId}`); return s; }); toast({ title: "Failed to toggle", variant: "destructive" }); }, }); const roles: AuthorityMatrixRole[] = roleMatrixQ.data?.roles ?? []; const roleCategories: Record = roleMatrixQ.data?.categories ?? {}; const matrixUsers: UserAuthorityMatrixUser[] = userMatrixQ.data?.users ?? []; const userCategories: Record = userMatrixQ.data?.categories ?? {}; const totalPerms = Object.values(roleCategories).reduce((s, p) => s + p.length, 0); const totalGranted = roles.reduce((s, r) => s + r.granted_permission_ids.length, 0); const totalCells = roles.length * totalPerms; const coveragePercent = totalCells > 0 ? Math.round((totalGranted / totalCells) * 100) : 0; const handleExportCsv = () => { const cats = tab === "roles" ? roleCategories : userCategories; const allPerms = Object.values(cats).flat(); if (tab === "roles") { const header = ["Permission Code", "Permission Name", "Category", ...roles.map(r => r.name)]; const rows = allPerms.map(p => { const cells = roles.map(r => r.granted_permission_ids.includes(p.id) ? "Yes" : "No"); return [p.code, p.name, p.category, ...cells]; }); downloadCsv([header, ...rows], "authority-matrix-roles.csv"); } else { const header = ["Permission Code", "Permission Name", "Category", ...matrixUsers.map(u => u.name)]; const rows = allPerms.map(p => { const cells = matrixUsers.map(u => u.granted_permission_ids.includes(p.id) ? "Yes" : "No"); return [p.code, p.name, p.category, ...cells]; }); downloadCsv([header, ...rows], "authority-matrix-users.csv"); } }; const loading = roleMatrixQ.isLoading || (tab === "users" && userMatrixQ.isLoading); if (roleMatrixQ.isLoading) { return
; } return (
{/* Header */}

Authority Matrix

Visual overview of permission assignments across roles and users.

{/* Summary */}

{roles.length}

Roles

{totalPerms}

Permissions

{totalGranted}

Granted

{coveragePercent}%

Coverage

{totalGranted}/{totalCells} cells

{/* Tabs & Search */}
Roles ({roles.length}) Users ({matrixUsers.length || "..."})
setSearch(e.target.value)} />
{/* Legend */}
Legend: Granted Not granted {tab === "roles" && Click any cell to toggle} {tab === "users" && User permissions are derived from roles (read-only)}
{/* ── Roles Matrix Tab ── */} toggleMut.mutate({ roleId: colId, permId })} renderColumnHeader={(r) => (
{r.name}
)} />
{/* ── Users Matrix Tab ── */} {userMatrixQ.isLoading ? (
) : matrixUsers.length === 0 ? ( No users with roles assigned. Assign roles in the User Roles page first. ) : ( (
{initials(u.name)} {u.name} {u.roles.length} role{u.roles.length !== 1 ? "s" : ""}
)} /> )}
); } function downloadCsv(rows: string[][], filename: string) { const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }