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:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 11a7265460
392 changed files with 62287 additions and 0 deletions

View 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);
}