Files
encoach_frontend_v4/src/pages/admin/RolesPermissions.tsx
Yamen Ahmad 11a7265460 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
2026-04-10 17:26:42 +04:00

382 lines
22 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Plus, Search, Trash2, Pencil, Shield, Key, Users, Loader2, ChevronRight,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { rolesService } from "@/services/roles.service";
import type { Role, Permission, RoleCreateRequest, PermissionCreateRequest } from "@/types/role-permission";
const CATEGORY_COLORS: Record<string, string> = {
exam: "bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
user: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
entity: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
assignment: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
classroom: "bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300",
payment: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
report: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
};
function categoryBadgeClass(cat: string) {
return CATEGORY_COLORS[cat.toLowerCase()] ?? "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
}
export default function RolesPermissions() {
const { toast } = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [tab, setTab] = useState("roles");
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [roleForm, setRoleForm] = useState<RoleCreateRequest>({ name: "", description: "" });
const [permDialogOpen, setPermDialogOpen] = useState(false);
const [permForm, setPermForm] = useState<PermissionCreateRequest>({ name: "", code: "", description: "", category: "" });
const [permAssignOpen, setPermAssignOpen] = useState(false);
const [assigningRole, setAssigningRole] = useState<Role | null>(null);
const [selectedPermIds, setSelectedPermIds] = useState<Set<number>>(new Set());
const rolesQ = useQuery({ queryKey: ["roles", search], queryFn: () => rolesService.listRoles(search ? { search } : undefined) });
const permsQ = useQuery({ queryKey: ["permissions"], queryFn: () => rolesService.listPermissions() });
const roles: Role[] = rolesQ.data?.data ?? [];
const permissions: Permission[] = permsQ.data?.data ?? [];
const createRoleMut = useMutation({
mutationFn: (d: RoleCreateRequest) => rolesService.createRole(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setRoleDialogOpen(false); toast({ title: "Role created" }); },
onError: () => toast({ title: "Failed to create role", variant: "destructive" }),
});
const updateRoleMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<RoleCreateRequest> }) => rolesService.updateRole(id, data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setRoleDialogOpen(false); setEditingRole(null); toast({ title: "Role updated" }); },
onError: () => toast({ title: "Failed to update role", variant: "destructive" }),
});
const deleteRoleMut = useMutation({
mutationFn: rolesService.deleteRole,
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); toast({ title: "Role deleted" }); },
onError: () => toast({ title: "Failed to delete role", variant: "destructive" }),
});
const assignPermsMut = useMutation({
mutationFn: ({ id, permIds }: { id: number; permIds: number[] }) => rolesService.updateRolePermissions(id, permIds),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setPermAssignOpen(false); toast({ title: "Permissions updated" }); },
onError: () => toast({ title: "Failed to update permissions", variant: "destructive" }),
});
const createPermMut = useMutation({
mutationFn: (d: PermissionCreateRequest) => rolesService.createPermission(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["permissions"] }); setPermDialogOpen(false); toast({ title: "Permission created" }); },
onError: () => toast({ title: "Failed to create permission", variant: "destructive" }),
});
const deletePermMut = useMutation({
mutationFn: rolesService.deletePermission,
onSuccess: () => { qc.invalidateQueries({ queryKey: ["permissions"] }); toast({ title: "Permission deleted" }); },
onError: () => toast({ title: "Failed to delete permission", variant: "destructive" }),
});
const loading = rolesQ.isLoading || permsQ.isLoading;
const openNewRole = () => { setEditingRole(null); setRoleForm({ name: "", description: "" }); setRoleDialogOpen(true); };
const openEditRole = (r: Role) => { setEditingRole(r); setRoleForm({ name: r.name, description: r.description }); setRoleDialogOpen(true); };
const openAssignPerms = (r: Role) => { setAssigningRole(r); setSelectedPermIds(new Set(r.permission_ids)); setPermAssignOpen(true); };
const openNewPerm = () => { setPermForm({ name: "", code: "", description: "", category: "" }); setPermDialogOpen(true); };
const handleSaveRole = () => {
if (!roleForm.name.trim()) return;
if (editingRole) {
updateRoleMut.mutate({ id: editingRole.id, data: roleForm });
} else {
createRoleMut.mutate(roleForm);
}
};
const togglePerm = (pid: number) => {
setSelectedPermIds(prev => { const s = new Set(prev); if (s.has(pid)) s.delete(pid); else s.add(pid); return s; });
};
const categorizedPerms = permissions.reduce<Record<string, Permission[]>>((acc, p) => {
const cat = p.category || "General";
(acc[cat] ??= []).push(p);
return acc;
}, {});
const filteredPerms = search
? permissions.filter(p => p.name.toLowerCase().includes(search.toLowerCase()) || p.code.toLowerCase().includes(search.toLowerCase()))
: permissions;
if (loading) {
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">
<Shield className="h-7 w-7 text-primary" />
Roles & Permissions
</h1>
<p className="text-muted-foreground">Manage platform roles and their permission assignments.</p>
</div>
<div className="flex gap-2">
{tab === "roles" ? (
<Button onClick={openNewRole}><Plus className="mr-2 h-4 w-4" /> New Role</Button>
) : (
<Button onClick={openNewPerm}><Plus className="mr-2 h-4 w-4" /> New Permission</Button>
)}
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card className="border-l-4 border-l-primary">
<CardContent className="pt-5 pb-4">
<div className="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-2xl font-bold">{roles.length}</p><p className="text-xs text-muted-foreground">Total Roles</p></div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center"><Key className="h-5 w-5 text-amber-500" /></div>
<div><p className="text-2xl font-bold">{permissions.length}</p><p className="text-xs text-muted-foreground">Total Permissions</p></div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-emerald-500/10 flex items-center justify-center"><Users className="h-5 w-5 text-emerald-500" /></div>
<div><p className="text-2xl font-bold">{Object.keys(categorizedPerms).length}</p><p className="text-xs text-muted-foreground">Permission Categories</p></div>
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<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="permissions" className="gap-1.5"><Key className="h-4 w-4" /> Permissions ({permissions.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={tab === "roles" ? "Search roles..." : "Search permissions..."} className="pl-9" value={search} onChange={e => setSearch(e.target.value)} />
</div>
</div>
{/* ── Roles Tab ── */}
<TabsContent value="roles" className="mt-4">
{roles.length === 0 ? (
<Card><CardContent className="py-16 text-center text-muted-foreground">No roles configured yet. Create one to get started.</CardContent></Card>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{roles.map(r => (
<Card key={r.id} className="group hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center">
<Shield className="h-4 w-4 text-primary" />
</div>
<div>
<CardTitle className="text-base">{r.name}</CardTitle>
<CardDescription className="text-xs">{r.user_count} user{r.user_count !== 1 ? "s" : ""} assigned</CardDescription>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip><TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => openEditRole(r)}><Pencil className="h-3.5 w-3.5" /></Button>
</TooltipTrigger><TooltipContent>Edit</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete role "${r.name}"?`)) deleteRoleMut.mutate(r.id); }}><Trash2 className="h-3.5 w-3.5" /></Button>
</TooltipTrigger><TooltipContent>Delete</TooltipContent></Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{r.description && <p className="text-sm text-muted-foreground line-clamp-2">{r.description}</p>}
<div className="flex flex-wrap gap-1.5">
{r.permissions.slice(0, 5).map(p => (
<Badge key={p.id} variant="outline" className={`text-[10px] px-1.5 py-0 ${categoryBadgeClass(p.category)}`}>{p.code}</Badge>
))}
{r.permissions.length > 5 && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">+{r.permissions.length - 5}</Badge>}
{r.permissions.length === 0 && <span className="text-xs text-muted-foreground italic">No permissions</span>}
</div>
<Button variant="outline" size="sm" className="w-full mt-2" onClick={() => openAssignPerms(r)}>
<Key className="mr-1.5 h-3.5 w-3.5" /> Manage Permissions <ChevronRight className="ml-auto h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* ── Permissions Tab ── */}
<TabsContent value="permissions" className="mt-4">
{Object.entries(categorizedPerms).length === 0 ? (
<Card><CardContent className="py-16 text-center text-muted-foreground">No permissions defined yet.</CardContent></Card>
) : (
<div className="space-y-4">
{Object.entries(categorizedPerms).map(([cat, perms]) => {
const visible = search ? perms.filter(p => filteredPerms.includes(p)) : perms;
if (visible.length === 0) return null;
return (
<Card key={cat}>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Badge className={`${categoryBadgeClass(cat)} font-medium`}>{cat}</Badge>
<span className="text-muted-foreground text-xs font-normal">{visible.length} permission{visible.length !== 1 ? "s" : ""}</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Code</TableHead>
<TableHead>Name</TableHead>
<TableHead className="hidden md:table-cell">Description</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map(p => (
<TableRow key={p.id}>
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{p.code}</code></TableCell>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground text-sm max-w-xs truncate">{p.description || "—"}</TableCell>
<TableCell>
<Button size="icon" variant="ghost" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete permission "${p.code}"?`)) deletePermMut.mutate(p.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
})}
</div>
)}
</TabsContent>
</Tabs>
{/* ── Create / Edit Role Dialog ── */}
<Dialog open={roleDialogOpen} onOpenChange={o => { setRoleDialogOpen(o); if (!o) setEditingRole(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>{editingRole ? "Edit Role" : "Create New Role"}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Role Name</Label><Input placeholder="e.g. Exam Manager" value={roleForm.name} onChange={e => setRoleForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2"><Label>Description</Label><Textarea placeholder="What does this role do?" value={roleForm.description} onChange={e => setRoleForm(f => ({ ...f, description: e.target.value }))} className="h-20" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>Cancel</Button>
<Button disabled={!roleForm.name.trim() || createRoleMut.isPending || updateRoleMut.isPending} onClick={handleSaveRole}>
{(createRoleMut.isPending || updateRoleMut.isPending) ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
{editingRole ? "Save Changes" : "Create Role"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Create Permission Dialog ── */}
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Permission</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Name</Label><Input placeholder="View Exams" value={permForm.name} onChange={e => setPermForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2"><Label>Code</Label><Input placeholder="exam.view" value={permForm.code} onChange={e => setPermForm(f => ({ ...f, code: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Category</Label><Input placeholder="exam" value={permForm.category} onChange={e => setPermForm(f => ({ ...f, category: e.target.value }))} /></div>
<div className="space-y-2"><Label>Description</Label><Textarea placeholder="Optional description" value={permForm.description} onChange={e => setPermForm(f => ({ ...f, description: e.target.value }))} className="h-16" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPermDialogOpen(false)}>Cancel</Button>
<Button disabled={!permForm.name.trim() || !permForm.code.trim() || createPermMut.isPending} onClick={() => createPermMut.mutate(permForm)}>
{createPermMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Create Permission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Assign Permissions Dialog ── */}
<Dialog open={permAssignOpen} onOpenChange={o => { setPermAssignOpen(o); if (!o) setAssigningRole(null); }}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
Permissions for "{assigningRole?.name}"
</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[55vh] space-y-4 pr-1">
{Object.entries(categorizedPerms).map(([cat, catPerms]) => (
<div key={cat} className="space-y-2">
<div className="flex items-center justify-between">
<Badge className={`${categoryBadgeClass(cat)} font-medium`}>{cat}</Badge>
<Button variant="ghost" size="sm" className="text-xs h-6" onClick={() => {
const allInCat = catPerms.map(p => p.id);
const allSelected = allInCat.every(id => selectedPermIds.has(id));
setSelectedPermIds(prev => {
const s = new Set(prev);
allInCat.forEach(id => allSelected ? s.delete(id) : s.add(id));
return s;
});
}}>
{catPerms.every(p => selectedPermIds.has(p.id)) ? "Deselect All" : "Select All"}
</Button>
</div>
<div className="grid gap-1.5">
{catPerms.map(p => (
<label key={p.id} className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/50 cursor-pointer transition-colors">
<Switch checked={selectedPermIds.has(p.id)} onCheckedChange={() => togglePerm(p.id)} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{p.name}</span>
<code className="text-[10px] bg-muted px-1 rounded font-mono text-muted-foreground">{p.code}</code>
</div>
{p.description && <p className="text-xs text-muted-foreground truncate">{p.description}</p>}
</div>
</label>
))}
</div>
</div>
))}
{permissions.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">No permissions available. Create some first.</p>}
</div>
<DialogFooter>
<div className="flex items-center gap-2 mr-auto text-sm text-muted-foreground">
<Key className="h-4 w-4" /> {selectedPermIds.size} of {permissions.length} selected
</div>
<Button variant="outline" onClick={() => setPermAssignOpen(false)}>Cancel</Button>
<Button disabled={assignPermsMut.isPending} onClick={() => { if (assigningRole) assignPermsMut.mutate({ id: assigningRole.id, permIds: Array.from(selectedPermIds) }); }}>
{assignPermsMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Save Permissions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}