feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
348
src/pages/admin/AuthorityMatrix.tsx
Normal file
348
src/pages/admin/AuthorityMatrix.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, Permission[]>, query: string) {
|
||||
if (!query) return categories;
|
||||
const q = query.toLowerCase();
|
||||
const result: Record<string, Permission[]> = {};
|
||||
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<T extends { id: number; granted_permission_ids: number[] }>({
|
||||
columns, categories, search, renderColumnHeader, onCellClick, pendingCells,
|
||||
}: {
|
||||
columns: T[];
|
||||
categories: Record<string, Permission[]>;
|
||||
search: string;
|
||||
renderColumnHeader: (col: T) => React.ReactNode;
|
||||
onCellClick?: (colId: number, permId: number) => void;
|
||||
pendingCells: Set<string>;
|
||||
}) {
|
||||
const filtered = useMemo(() => filterCategories(categories, search), [categories, search]);
|
||||
|
||||
if (columns.length === 0) {
|
||||
return <Card><CardContent className="py-16 text-center text-muted-foreground">No data available.</CardContent></Card>;
|
||||
}
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return <Card><CardContent className="py-16 text-center text-muted-foreground">No permissions match your search.</CardContent></Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(filtered).map(([cat, perms]) => (
|
||||
<Card key={cat} className={`border-l-4 ${CATEGORY_COLORS[cat.toLowerCase()] ?? "border-l-gray-400 bg-gray-50 dark:bg-gray-950"}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Badge className={`${CATEGORY_BADGE[cat.toLowerCase()] ?? "bg-gray-100 text-gray-700"} font-medium text-xs`}>{cat}</Badge>
|
||||
<span className="text-muted-foreground font-normal">{perms.length} permission{perms.length !== 1 ? "s" : ""}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left font-medium text-muted-foreground pb-2 pr-4 min-w-[200px] sticky left-0 bg-card z-10">Permission</th>
|
||||
{columns.map(col => (
|
||||
<th key={col.id} className="text-center font-medium text-muted-foreground pb-2 px-2 min-w-[100px]">
|
||||
{renderColumnHeader(col)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{perms.map((p, idx) => (
|
||||
<tr key={p.id} className={idx % 2 === 0 ? "" : "bg-muted/30"}>
|
||||
<td className="py-1.5 pr-4 sticky left-0 bg-card z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 cursor-default">
|
||||
<code className="text-[10px] font-mono text-muted-foreground bg-muted px-1 rounded">{p.code}</code>
|
||||
<span className="text-xs font-medium truncate max-w-[140px]">{p.name}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<p className="font-medium">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.description || "No description"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
{columns.map(col => {
|
||||
const granted = col.granted_permission_ids.includes(p.id);
|
||||
const cellKey = `${col.id}-${p.id}`;
|
||||
const pending = pendingCells.has(cellKey);
|
||||
return (
|
||||
<td key={col.id} className="text-center py-1.5 px-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onCellClick?.(col.id, p.id)}
|
||||
disabled={pending || !onCellClick}
|
||||
className={`
|
||||
inline-flex items-center justify-center h-7 w-7 rounded-md border transition-all duration-150
|
||||
${pending ? "opacity-50 cursor-wait" : onCellClick ? "cursor-pointer hover:scale-110" : "cursor-default"}
|
||||
${granted
|
||||
? "bg-emerald-500/20 border-emerald-500/40 hover:bg-emerald-500/30"
|
||||
: "bg-muted/50 border-border hover:bg-muted hover:border-muted-foreground/30"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
) : granted ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-600" />
|
||||
) : (
|
||||
<X className="h-3 w-3 text-muted-foreground/30" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{granted ? "Granted" : "Not granted"}: <strong>{p.code}</strong>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthorityMatrix() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [tab, setTab] = useState("roles");
|
||||
const [pendingCells, setPendingCells] = useState<Set<string>>(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<string, Permission[]> = roleMatrixQ.data?.categories ?? {};
|
||||
const matrixUsers: UserAuthorityMatrixUser[] = userMatrixQ.data?.users ?? [];
|
||||
const userCategories: Record<string, Permission[]> = 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 <div className="flex items-center justify-center min-h-[400px]"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Grid3X3 className="h-7 w-7 text-primary" />
|
||||
Authority Matrix
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Visual overview of permission assignments across roles and users.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleExportCsv}><Download className="mr-2 h-4 w-4" /> Export CSV</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><Shield className="h-5 w-5 text-primary" /></div>
|
||||
<div><p className="text-xl font-bold">{roles.length}</p><p className="text-xs text-muted-foreground">Roles</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center"><Grid3X3 className="h-5 w-5 text-amber-500" /></div>
|
||||
<div><p className="text-xl font-bold">{totalPerms}</p><p className="text-xs text-muted-foreground">Permissions</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-emerald-500/10 flex items-center justify-center"><Check className="h-5 w-5 text-emerald-500" /></div>
|
||||
<div><p className="text-xl font-bold">{totalGranted}</p><p className="text-xs text-muted-foreground">Granted</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-blue-500">{coveragePercent}%</span>
|
||||
</div>
|
||||
<div><p className="text-xl font-bold">Coverage</p><p className="text-xs text-muted-foreground">{totalGranted}/{totalCells} cells</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs & Search */}
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="roles" className="gap-1.5"><Shield className="h-4 w-4" /> Roles ({roles.length})</TabsTrigger>
|
||||
<TabsTrigger value="users" className="gap-1.5"><Users className="h-4 w-4" /> Users ({matrixUsers.length || "..."})</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="relative max-w-xs w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Filter permissions..." className="pl-9" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mt-4">
|
||||
<span className="font-medium text-foreground">Legend:</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-flex h-5 w-5 rounded bg-emerald-500/20 border border-emerald-500/40 items-center justify-center"><Check className="h-3 w-3 text-emerald-600" /></span>
|
||||
Granted
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-flex h-5 w-5 rounded bg-muted border border-border items-center justify-center"><X className="h-3 w-3 text-muted-foreground/40" /></span>
|
||||
Not granted
|
||||
</span>
|
||||
{tab === "roles" && <span className="ml-auto">Click any cell to toggle</span>}
|
||||
{tab === "users" && <span className="ml-auto text-muted-foreground/70">User permissions are derived from roles (read-only)</span>}
|
||||
</div>
|
||||
|
||||
{/* ── Roles Matrix Tab ── */}
|
||||
<TabsContent value="roles" className="mt-4">
|
||||
<MatrixGrid
|
||||
columns={roles}
|
||||
categories={roleCategories}
|
||||
search={search}
|
||||
pendingCells={pendingCells}
|
||||
onCellClick={(colId, permId) => toggleMut.mutate({ roleId: colId, permId })}
|
||||
renderColumnHeader={(r) => (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Shield className="h-3.5 w-3.5 text-primary/60" />
|
||||
<span className="text-xs leading-tight">{r.name}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Users Matrix Tab ── */}
|
||||
<TabsContent value="users" className="mt-4">
|
||||
{userMatrixQ.isLoading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||
) : matrixUsers.length === 0 ? (
|
||||
<Card><CardContent className="py-16 text-center text-muted-foreground">No users with roles assigned. Assign roles in the User Roles page first.</CardContent></Card>
|
||||
) : (
|
||||
<MatrixGrid
|
||||
columns={matrixUsers}
|
||||
categories={userCategories}
|
||||
search={search}
|
||||
pendingCells={new Set()}
|
||||
renderColumnHeader={(u) => (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[9px] bg-primary/10 text-primary font-medium">{initials(u.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[10px] leading-tight max-w-[80px] truncate">{u.name}</span>
|
||||
<span className="text-[9px] text-muted-foreground">{u.roles.length} role{u.roles.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user