custom RBAC integration

This commit is contained in:
Yared Yemane 2026-03-05 07:59:29 -08:00
parent 95f5d37878
commit 3ecd35f960
20 changed files with 2162 additions and 1032 deletions

3
.env
View File

@ -1,2 +1,3 @@
VITE_API_BASE_URL=http://api.yimaru.yaltopia.com/
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
VITE_API_BASE_URL=http://localhost:8080/api/v1
VITE_GOOGLE_CLIENT_ID=

76
package-lock.json generated
View File

@ -8,6 +8,9 @@
"name": "yimaru-admin",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/inter": "^5.2.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
@ -88,6 +91,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -339,6 +343,60 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@ -2753,6 +2811,7 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -2763,6 +2822,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -2773,6 +2833,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -2828,6 +2889,7 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@ -3079,6 +3141,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3317,6 +3380,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3893,6 +3957,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5007,6 +5072,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -5054,6 +5120,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -5242,6 +5309,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5251,6 +5319,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -5270,6 +5339,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -5475,7 +5545,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -5879,6 +5950,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -6046,6 +6118,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -6183,6 +6256,7 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/inter": "^5.2.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",

View File

@ -38,6 +38,10 @@ import type {
CreateQuestionResponse,
CreateVimeoVideoRequest,
CreateCourseCategoryRequest,
GetSubCoursePrerequisitesResponse,
AddSubCoursePrerequisiteRequest,
GetLearningPathResponse,
ReorderItem,
} from "../types/course.types"
export const getCourseCategories = () =>
@ -195,3 +199,20 @@ export const deleteQuestionSet = (questionSetId: number) =>
export const createVimeoVideo = (data: CreateVimeoVideoRequest) =>
http.post("/course-management/videos/vimeo", data)
// Sub-course Prerequisite APIs
export const getSubCoursePrerequisites = (subCourseId: number) =>
http.get<GetSubCoursePrerequisitesResponse>(`/course-management/sub-courses/${subCourseId}/prerequisites`)
export const addSubCoursePrerequisite = (subCourseId: number, data: AddSubCoursePrerequisiteRequest) =>
http.post(`/course-management/sub-courses/${subCourseId}/prerequisites`, data)
export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId: number) =>
http.delete(`/course-management/sub-courses/${subCourseId}/prerequisites/${prerequisiteId}`)
// Learning Path APIs
export const getLearningPath = (courseId: number) =>
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
export const reorderSubCourses = (courseId: number, items: ReorderItem[]) =>
http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items })

View File

@ -7,6 +7,8 @@ import type {
IssueFilters,
} from "../types/issue.types";
import type { CreateIssueRequest, CreateIssueResponse } from "../types/issue.types";
export const getIssues = (filters?: IssueFilters) =>
http.get<GetIssuesResponse>("/issues", {
params: filters,
@ -18,6 +20,9 @@ export const getIssuesByUserId = (userId: number) =>
export const getIssueById = (id: number) =>
http.get<GetIssueResponse>(`/issues/${id}`);
export const createIssue = (payload: CreateIssueRequest) =>
http.post<CreateIssueResponse>("/issues", payload);
export const updateIssueStatus = (id: number, status: string) =>
http.patch<UpdateIssueStatusResponse>(`/issues/${id}/status`, { status });

View File

@ -20,3 +20,16 @@ export const markAllRead = () =>
export const markAllUnread = () =>
http.post("/notifications/mark-all-unread");
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
http.post("/notifications/bulk-sms", data);
export const sendBulkEmail = (formData: FormData) =>
http.post("/notifications/bulk-email", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
export const sendBulkPush = (formData: FormData) =>
http.post("/notifications/bulk-push", formData, {
headers: { "Content-Type": "multipart/form-data" },
});

25
src/api/rbac.api.ts Normal file
View File

@ -0,0 +1,25 @@
import http from "./http"
import type {
GetRolesResponse,
GetRoleDetailResponse,
GetRolesParams,
CreateRoleRequest,
CreateRoleResponse,
SetRolePermissionsRequest,
GetPermissionsResponse,
} from "../types/rbac.types"
export const getRoles = (params?: GetRolesParams) =>
http.get<GetRolesResponse>("/rbac/roles", { params })
export const getRoleDetail = (roleId: number) =>
http.get<GetRoleDetailResponse>(`/rbac/roles/${roleId}`)
export const createRole = (data: CreateRoleRequest) =>
http.post<CreateRoleResponse>("/rbac/roles", data)
export const setRolePermissions = (roleId: number, data: SetRolePermissionsRequest) =>
http.put(`/rbac/roles/${roleId}/permissions`, data)
export const getAllPermissions = () =>
http.get<GetPermissionsResponse>("/rbac/permissions")

View File

@ -1,9 +1,18 @@
import http from "./http";
import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types";
export const getUsers = (page?: number, pageSize?: number) =>
export const getUsers = (
page?: number,
pageSize?: number,
role?: string,
status?: string,
query?: string,
) =>
http.get<GetUsersResponse>("/users", {
params: {
role,
status,
query,
page,
page_size: pageSize,
},

View File

@ -291,15 +291,12 @@ export function CourseCategoryPage() {
setCreating(true)
try {
const name = newCategoryName.trim()
const parentPayloadId = parentCategoryId ?? null
const parentRes = await createCourseCategory({
name: newCategoryName.trim(),
parent_id: parentPayloadId,
})
const parentRes = await createCourseCategory({ name })
let createdCategoryId: number | null = null
try {
const data: any = parentRes?.data
createdCategoryId =
data?.data?.id ??
data?.data?.category?.id ??
data?.data?.id ??
data?.category?.id ??
@ -312,10 +309,7 @@ export function CourseCategoryPage() {
if (createdCategoryId && pendingSubCategories.length > 0) {
await Promise.all(
pendingSubCategories.map((subName) =>
createCourseCategory({
name: subName,
parent_id: createdCategoryId,
}),
createCourseCategory({ name: subName }),
),
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,24 @@
import { useEffect, useState, useRef } from "react"
import { useEffect, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom"
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit, AlertCircle } from "lucide-react"
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle } from "lucide-react"
import practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent } from "../../components/ui/card"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
import { FileUpload } from "../../components/ui/file-upload"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types"
function CourseThumbnail({ src, alt, gradient }: { src?: string; alt: string; gradient: string }) {
const [imgError, setImgError] = useState(false)
if (!src || imgError) {
return <div className={`h-full w-full rounded-t-lg ${gradient}`} />
}
return (
<img
src={src}
alt={alt}
className="h-full w-full object-cover rounded-t-lg"
onError={() => setImgError(true)}
/>
)
}
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
const navigate = useNavigate()
@ -42,16 +32,10 @@ export function CoursesPage() {
const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [newThumbnailFile, setNewThumbnailFile] = useState<File | null>(null)
const [newVideoFile, setNewVideoFile] = useState<File | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
const [deleting, setDeleting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [courseToEdit, setCourseToEdit] = useState<Course | null>(null)
const [editTitle, setEditTitle] = useState("")
@ -60,19 +44,6 @@ export function CoursesPage() {
const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpenMenuId(null)
}
}
if (openMenuId !== null) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [openMenuId])
const fetchCourses = async () => {
if (!categoryId) return
@ -95,7 +66,7 @@ export function CoursesPage() {
getCourseCategories(),
])
setCourses(coursesRes.data.data.courses)
setCourses(coursesRes.data.data.courses ?? [])
const foundCategory = categoriesRes.data.data.categories.find(
(c) => c.id === Number(categoryId)
)
@ -115,8 +86,6 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
setShowModal(true)
}
@ -125,8 +94,6 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
}
const handleSave = async () => {
@ -296,127 +263,123 @@ export function CoursesPage() {
</div>
</div>
{/* Course grid or empty state */}
{courses.length === 0 ? (
<Card className="border-dashed border-grayScale-200 shadow-none">
<CardContent className="flex flex-col items-center justify-center py-20">
<img src={practiceSrc} alt="" className="h-20 w-20" />
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No courses yet</h3>
<p className="mt-1.5 text-sm text-grayScale-400">No courses found in this category</p>
<Button variant="outline" className="mt-6 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50" onClick={handleOpenModal}>
<Plus className="mr-2 h-4 w-4" />
Add your first course
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{courses.map((course, index) => {
const gradients = [
"bg-gradient-to-br from-blue-100 to-blue-200",
"bg-gradient-to-br from-purple-100 to-purple-200",
"bg-gradient-to-br from-green-100 to-green-200",
"bg-gradient-to-br from-yellow-100 to-yellow-200",
]
return (
<Card
key={course.id}
className="group cursor-pointer overflow-hidden border border-grayScale-100 bg-white shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-grayScale-200"
onClick={() => handleCourseClick(course.id)}
{/* Course table or empty state */}
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-base font-semibold text-grayScale-600">
Course Management
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
{courses.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
<img src={practiceSrc} alt="" className="h-16 w-16" />
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No courses yet</h3>
<p className="mt-1.5 text-sm text-grayScale-400">
No courses found in this category.
</p>
<Button
variant="outline"
className="mt-5 border-brand-200 text-brand-600 transition-colors hover:bg-brand-50"
onClick={handleOpenModal}
>
{/* Thumbnail */}
<div className="relative aspect-video w-full overflow-hidden">
<CourseThumbnail
src={course.thumbnail}
alt={course.title}
gradient={gradients[index % gradients.length]}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</div>
{/* Content */}
<div className="space-y-3 border-t border-grayScale-50 p-4">
{/* Status and menu */}
<div className="flex items-center justify-between">
<Badge
className={`rounded-full px-2.5 py-0.5 text-[11px] font-semibold tracking-wide ${
course.is_active
? "border-0 bg-emerald-50 text-emerald-700"
: "border-0 bg-grayScale-100 text-grayScale-500"
<Plus className="mr-2 h-4 w-4" />
Add your first course
</Button>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Course
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course, index) => (
<TableRow
key={course.id}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
}`}
onClick={() => handleCourseClick(course.id)}
>
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${course.is_active ? "bg-emerald-500" : "bg-grayScale-400"}`} />
{course.is_active ? "ACTIVE" : "INACTIVE"}
</Badge>
<div className="relative" ref={openMenuId === course.id ? menuRef : undefined} onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setOpenMenuId(openMenuId === course.id ? null : course.id)}
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
>
<MoreVertical className="h-4 w-4" />
</button>
{openMenuId === course.id && (
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 animate-in fade-in slide-in-from-top-1 rounded-xl border border-grayScale-100 bg-white py-1.5 shadow-lg">
<button
onClick={() => {
handleToggleStatus(course)
setOpenMenuId(null)
<TableCell className="max-w-md py-3.5">
<div className="truncate text-sm font-semibold text-grayScale-700">
{course.title}
</div>
{course.description && (
<div className="mt-1 truncate text-xs text-grayScale-400">
{course.description}
</div>
)}
</TableCell>
<TableCell className="hidden py-3.5 md:table-cell">
<Badge
variant={course.is_active ? "success" : "secondary"}
className="text-[11px] font-semibold"
>
{course.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={(e) => {
e.stopPropagation()
handleEditClick(course)
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
disabled={togglingId === course.id}
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
onClick={(e) => {
e.stopPropagation()
handleToggleStatus(course)
}}
>
{course.is_active ? (
<>
<ToggleLeft className="h-4 w-4" />
Deactivate
</>
<ToggleLeft className="h-4 w-4" />
) : (
<>
<ToggleRight className="h-4 w-4" />
Activate
</>
<ToggleRight className="h-4 w-4" />
)}
</button>
<div className="mx-3 my-1 border-t border-grayScale-100" />
<button
onClick={() => {
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick(course)
setOpenMenuId(null)
}}
className="flex w-full items-center gap-2.5 px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</Button>
</div>
)}
</div>
</div>
{/* Title */}
<h3 className="font-semibold text-grayScale-700 line-clamp-1">{course.title}</h3>
<p className="text-sm leading-relaxed text-grayScale-400 line-clamp-2">
{course.description || "No description available"}
</p>
{/* Edit button */}
<Button
variant="outline"
className="w-full border-grayScale-200 text-grayScale-600 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
onClick={(e) => {
e.stopPropagation()
handleEditClick(course)
}}
>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</div>
</Card>
)
})}
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Add Course Modal */}
{showModal && (
@ -472,29 +435,6 @@ export function CoursesPage() {
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-grayScale-600">Thumbnail image</p>
<FileUpload
accept="image/*"
onFileSelect={setNewThumbnailFile}
label="Upload thumbnail"
description="Optional course cover image"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
<div>
<p className="mb-2 text-sm font-medium text-grayScale-600">Intro video</p>
<FileUpload
accept="video/*"
onFileSelect={setNewVideoFile}
label="Upload intro video"
description="Optional overview for this course"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
</div>
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div>

View File

@ -1,15 +1,15 @@
import { useEffect, useState, useRef } from "react"
import { Link, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, AlertCircle, Edit } from "lucide-react"
import { ArrowLeft, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, AlertCircle, Edit, Link2, Plus, Loader2, LayoutGrid, GitBranch, ChevronDown, Lock, ArrowRight } from "lucide-react"
import practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse } from "../../api/courses.api"
import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse, getSubCoursePrerequisites, addSubCoursePrerequisite, removeSubCoursePrerequisite } from "../../api/courses.api"
import { Input } from "../../components/ui/input"
import type { SubCourse, Course, CourseCategory } from "../../types/course.types"
import type { SubCourse, Course, CourseCategory, SubCoursePrerequisite } from "../../types/course.types"
export function SubCoursesPage() {
const { categoryId, courseId } = useParams<{ categoryId: string; courseId: string }>()
@ -36,6 +36,22 @@ export function SubCoursesPage() {
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// View mode
const [viewMode, setViewMode] = useState<"grid" | "flow">("grid")
// All prerequisites map: subCourseId -> prerequisites[]
const [allPrereqMap, setAllPrereqMap] = useState<Record<number, SubCoursePrerequisite[]>>({})
const [allPrereqLoading, setAllPrereqLoading] = useState(false)
// Prerequisites state
const [showPrereqModal, setShowPrereqModal] = useState(false)
const [prereqSubCourse, setPrereqSubCourse] = useState<SubCourse | null>(null)
const [prerequisites, setPrerequisites] = useState<SubCoursePrerequisite[]>([])
const [prereqLoading, setPrereqLoading] = useState(false)
const [prereqAdding, setPrereqAdding] = useState(false)
const [prereqRemoving, setPrereqRemoving] = useState<number | null>(null)
const [selectedPrereqId, setSelectedPrereqId] = useState<number | 0>(0)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
@ -60,6 +76,25 @@ export function SubCoursesPage() {
}
}
const fetchAllPrerequisites = async (scs: SubCourse[]) => {
if (scs.length === 0) return
setAllPrereqLoading(true)
try {
const results = await Promise.all(
scs.map((sc) => getSubCoursePrerequisites(sc.id).then((res) => ({ id: sc.id, data: res.data.data ?? [] })))
)
const map: Record<number, SubCoursePrerequisite[]> = {}
for (const r of results) {
map[r.id] = r.data
}
setAllPrereqMap(map)
} catch (err) {
console.error("Failed to fetch all prerequisites:", err)
} finally {
setAllPrereqLoading(false)
}
}
useEffect(() => {
const fetchData = async () => {
if (!courseId || !categoryId) return
@ -93,6 +128,12 @@ export function SubCoursesPage() {
fetchData()
}, [courseId, categoryId])
useEffect(() => {
if (subCourses.length > 0) {
fetchAllPrerequisites(subCourses)
}
}, [subCourses])
const handleToggleStatus = async (subCourse: SubCourse) => {
setTogglingId(subCourse.id)
try {
@ -199,6 +240,112 @@ export function SubCoursesPage() {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
}
const handlePrereqClick = async (subCourse: SubCourse) => {
setPrereqSubCourse(subCourse)
setShowPrereqModal(true)
setPrereqLoading(true)
setSelectedPrereqId(0)
try {
const res = await getSubCoursePrerequisites(subCourse.id)
setPrerequisites(res.data.data ?? [])
} catch (err) {
console.error("Failed to fetch prerequisites:", err)
setPrerequisites([])
} finally {
setPrereqLoading(false)
}
}
const handleAddPrerequisite = async () => {
if (!prereqSubCourse || !selectedPrereqId) return
setPrereqAdding(true)
try {
await addSubCoursePrerequisite(prereqSubCourse.id, {
prerequisite_sub_course_id: selectedPrereqId,
})
const res = await getSubCoursePrerequisites(prereqSubCourse.id)
setPrerequisites(res.data.data ?? [])
setSelectedPrereqId(0)
} catch (err) {
console.error("Failed to add prerequisite:", err)
} finally {
setPrereqAdding(false)
}
}
const handleRemovePrerequisite = async (prereqId: number) => {
if (!prereqSubCourse) return
setPrereqRemoving(prereqId)
try {
await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId)
const res = await getSubCoursePrerequisites(prereqSubCourse.id)
setPrerequisites(res.data.data ?? [])
} catch (err) {
console.error("Failed to remove prerequisite:", err)
} finally {
setPrereqRemoving(null)
}
}
// Build flow layers using topological sort
const flowLayers = (() => {
if (subCourses.length === 0) return []
// Find sub-courses with no prerequisites (roots)
const hasPrereqs = new Set<number>()
const isPrereqOf = new Map<number, number[]>() // prereqId -> [subCourseIds that depend on it]
for (const sc of subCourses) {
const prereqs = allPrereqMap[sc.id] ?? []
if (prereqs.length > 0) {
hasPrereqs.add(sc.id)
}
for (const p of prereqs) {
const dependents = isPrereqOf.get(p.prerequisite_sub_course_id) ?? []
dependents.push(sc.id)
isPrereqOf.set(p.prerequisite_sub_course_id, dependents)
}
}
// BFS-based layering
const layers: SubCourse[][] = []
const placed = new Set<number>()
// Layer 0: no prerequisites
const roots = subCourses.filter((sc) => !hasPrereqs.has(sc.id))
if (roots.length > 0) {
layers.push(roots)
roots.forEach((sc) => placed.add(sc.id))
}
// Subsequent layers: all prereqs already placed
let maxIterations = subCourses.length
while (placed.size < subCourses.length && maxIterations-- > 0) {
const nextLayer = subCourses.filter((sc) => {
if (placed.has(sc.id)) return false
const prereqs = allPrereqMap[sc.id] ?? []
return prereqs.every((p) => placed.has(p.prerequisite_sub_course_id))
})
if (nextLayer.length === 0) {
// Remaining have circular deps or missing prereqs — just add them
const remaining = subCourses.filter((sc) => !placed.has(sc.id))
if (remaining.length > 0) layers.push(remaining)
break
}
layers.push(nextLayer)
nextLayer.forEach((sc) => placed.add(sc.id))
}
return layers
})()
const availablePrerequisites = subCourses.filter(
(sc) =>
prereqSubCourse &&
sc.id !== prereqSubCourse.id &&
!prerequisites.some((p) => p.prerequisite_sub_course_id === sc.id)
)
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-24">
@ -243,9 +390,37 @@ export function SubCoursesPage() {
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
</div>
</div>
<Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
Add New Sub-course
</Button>
<div className="flex items-center gap-2">
{subCourses.length > 0 && (
<div className="flex rounded-xl border border-grayScale-200 bg-white p-0.5 shadow-sm">
<button
onClick={() => setViewMode("grid")}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
viewMode === "grid"
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700"
}`}
>
<LayoutGrid className="h-3.5 w-3.5" />
Grid
</button>
<button
onClick={() => setViewMode("flow")}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
viewMode === "flow"
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700"
}`}
>
<GitBranch className="h-3.5 w-3.5" />
Flow
</button>
</div>
)}
<Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
Add New Sub-course
</Button>
</div>
</div>
{/* Sub-course grid or empty state */}
@ -320,6 +495,17 @@ export function SubCoursesPage() {
</button>
{openMenuId === subCourse.id && (
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
<button
onClick={() => {
handlePrereqClick(subCourse)
setOpenMenuId(null)
}}
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50"
>
<Link2 className="h-4 w-4" />
Prerequisites
</button>
<div className="mx-3 border-t border-grayScale-100" />
<button
onClick={() => {
handleToggleStatus(subCourse)
@ -499,6 +685,118 @@ export function SubCoursesPage() {
</div>
)}
{/* Prerequisites Modal */}
{showPrereqModal && prereqSubCourse && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-lg rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-grayScale-700">Prerequisites</h2>
<p className="mt-0.5 text-sm text-grayScale-400">
Manage prerequisites for <span className="font-medium text-grayScale-600">{prereqSubCourse.title}</span>
</p>
</div>
<button
onClick={() => setShowPrereqModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-6 py-5">
{/* Add prerequisite */}
{availablePrerequisites.length > 0 && (
<div className="mb-5">
<label className="mb-1.5 block text-sm font-semibold text-grayScale-600">Add Prerequisite</label>
<div className="flex gap-2">
<select
value={selectedPrereqId}
onChange={(e) => setSelectedPrereqId(Number(e.target.value))}
className="flex-1 rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
>
<option value={0}>Select a sub-course...</option>
{availablePrerequisites.map((sc) => (
<option key={sc.id} value={sc.id}>
{sc.title} {sc.level ? `(${sc.level})` : ""}
</option>
))}
</select>
<Button
className="shrink-0 rounded-lg bg-brand-500 px-4 shadow-sm hover:bg-brand-600"
onClick={handleAddPrerequisite}
disabled={prereqAdding || !selectedPrereqId}
>
{prereqAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
</div>
)}
{/* Current prerequisites list */}
{prereqLoading ? (
<div className="flex items-center justify-center py-8">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
</div>
) : prerequisites.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 px-4 py-8 text-center">
<Link2 className="mx-auto h-8 w-8 text-grayScale-300" />
<p className="mt-2 text-sm font-medium text-grayScale-500">No prerequisites</p>
<p className="mt-0.5 text-xs text-grayScale-400">This sub-course is accessible without completing others first</p>
</div>
) : (
<div className="space-y-2">
<p className="text-sm font-semibold text-grayScale-600">
Current Prerequisites ({prerequisites.length})
</p>
{prerequisites.map((prereq) => (
<div
key={prereq.id}
className="flex items-center justify-between rounded-xl border border-grayScale-100 bg-grayScale-25 px-4 py-3 transition-colors hover:border-grayScale-200"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-grayScale-700 truncate">{prereq.prerequisite_title}</p>
<div className="mt-0.5 flex items-center gap-2">
{prereq.prerequisite_level && (
<span className="rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-600">
{prereq.prerequisite_level}
</span>
)}
<span className="text-[11px] text-grayScale-400">
Order: {prereq.prerequisite_display_order}
</span>
</div>
</div>
<button
onClick={() => handleRemovePrerequisite(prereq.id)}
disabled={prereqRemoving === prereq.id}
className="ml-3 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
>
{prereqRemoving === prereq.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end border-t border-grayScale-100 px-6 py-4">
<Button
variant="outline"
onClick={() => setShowPrereqModal(false)}
className="rounded-lg"
>
Close
</Button>
</div>
</div>
</div>
)}
{/* Edit Sub-course Modal */}
{showEditModal && subCourseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">

View File

@ -46,6 +46,7 @@ import {
getIssueById,
updateIssueStatus,
deleteIssue,
createIssue,
} from "../../api/issues.api";
import type { Issue, IssueFilters } from "../../types/issue.types";
@ -207,6 +208,9 @@ export function IssuesPage() {
const [createSubject, setCreateSubject] = useState("");
const [createType, setCreateType] = useState<string>("bug");
const [createDescription, setCreateDescription] = useState("");
const [createDevice, setCreateDevice] = useState("");
const [createBrowser, setCreateBrowser] = useState("");
const [createSubmitting, setCreateSubmitting] = useState(false);
const fetchIssues = useCallback(async () => {
setLoading(true);
@ -522,7 +526,6 @@ export function IssuesPage() {
const typeConfig = getIssueTypeConfig(issue.issue_type);
const statusConfig = getStatusConfig(issue.status);
const TypeIcon = typeConfig.icon;
const StatusIcon = statusConfig.icon;
return (
<TableRow key={issue.id} className="group">
@ -907,6 +910,29 @@ export function IssuesPage() {
onChange={(e) => setCreateDescription(e.target.value)}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Device (optional)
</label>
<Input
placeholder="e.g. iPhone 14"
value={createDevice}
onChange={(e) => setCreateDevice(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Browser (optional)
</label>
<Input
placeholder="e.g. Safari 17"
value={createBrowser}
onChange={(e) => setCreateBrowser(e.target.value)}
/>
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
@ -917,24 +943,49 @@ export function IssuesPage() {
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
setCreateDevice("");
setCreateBrowser("");
}}
disabled={createSubmitting}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => {
// Hook to create-issue API here; currently UI-only.
if (!createSubject.trim() || !createDescription.trim()) {
return;
disabled={createSubmitting || !createSubject.trim() || !createDescription.trim()}
onClick={async () => {
if (!createSubject.trim() || !createDescription.trim()) return;
setCreateSubmitting(true);
try {
const payload: any = {
subject: createSubject.trim(),
description: createDescription.trim(),
issue_type: createType,
};
const metadata: Record<string, string> = {};
if (createDevice.trim()) metadata.device = createDevice.trim();
if (createBrowser.trim()) metadata.browser = createBrowser.trim();
if (Object.keys(metadata).length > 0) {
payload.metadata = metadata;
}
await createIssue(payload);
setCreateOpen(false);
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
setCreateDevice("");
setCreateBrowser("");
fetchIssues();
} catch (error) {
console.error("Failed to create issue:", error);
} finally {
setCreateSubmitting(false);
}
setCreateOpen(false);
setCreateSubject("");
setCreateDescription("");
setCreateType("bug");
}}
>
Create Issue
{createSubmitting ? "Creating..." : "Create Issue"}
</Button>
</div>
</DialogContent>

View File

@ -44,10 +44,14 @@ import {
markAsUnread,
markAllRead,
markAllUnread,
sendBulkSms,
sendBulkEmail,
sendBulkPush,
} from "../../api/notifications.api"
import { getTeamMembers } from "../../api/team.api"
import type { Notification } from "../../types/notification.types"
import type { TeamMember } from "../../types/team.types"
import { toast } from "sonner"
const PAGE_SIZE = 10
@ -261,6 +265,16 @@ export function NotificationsPage() {
const [composeOpen, setComposeOpen] = useState(false)
const [composeImage, setComposeImage] = useState<File | null>(null)
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms")
const [bulkTitle, setBulkTitle] = useState("")
const [bulkMessage, setBulkMessage] = useState("")
const [bulkRole, setBulkRole] = useState("")
const [bulkUserIds, setBulkUserIds] = useState("")
const [bulkScheduledAt, setBulkScheduledAt] = useState("")
const [bulkFile, setBulkFile] = useState<File | null>(null)
const [bulkSending, setBulkSending] = useState(false)
const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true)
setError(false)
@ -418,10 +432,10 @@ export function NotificationsPage() {
<Button
size="sm"
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setComposeOpen(true)}
onClick={() => setBulkOpen(true)}
>
<Megaphone className="mr-2 h-3.5 w-3.5" />
New notification
<Mail className="mr-2 h-3.5 w-3.5" />
Send notification
</Button>
{notifications.length > 0 && (
<>
@ -1073,6 +1087,243 @@ export function NotificationsPage() {
</form>
</DialogContent>
</Dialog>
{/* Bulk send dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-brand-500" />
<span>Send notification</span>
</DialogTitle>
<DialogDescription>
Send a bulk SMS, email, or push notification to users.
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={async (e) => {
e.preventDefault()
if (!bulkMessage.trim()) {
toast.error("Message is required")
return
}
const trimmedIds = bulkUserIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
const userIds = trimmedIds.map((id) => Number(id)).filter((id) => !Number.isNaN(id))
try {
setBulkSending(true)
if (bulkChannel === "sms") {
if (userIds.length === 0) {
toast.error("User IDs are required for bulk SMS")
setBulkSending(false)
return
}
await sendBulkSms({
message: bulkMessage.trim(),
user_ids: userIds,
...(bulkScheduledAt ? { scheduled_at: bulkScheduledAt } : {}),
})
} else if (bulkChannel === "email") {
const form = new FormData()
if (!bulkTitle.trim()) {
toast.error("Subject is required for bulk email")
setBulkSending(false)
return
}
form.append("subject", bulkTitle.trim())
form.append("message", bulkMessage.trim())
if (bulkRole.trim()) form.append("role", bulkRole.trim())
if (userIds.length > 0) {
form.append("user_ids", JSON.stringify(userIds))
}
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
if (bulkFile) form.append("file", bulkFile)
await sendBulkEmail(form)
} else {
const form = new FormData()
if (!bulkTitle.trim()) {
toast.error("Title is required for bulk push")
setBulkSending(false)
return
}
form.append("title", bulkTitle.trim())
form.append("message", bulkMessage.trim())
if (bulkRole.trim()) form.append("role", bulkRole.trim())
if (userIds.length > 0) {
form.append("user_ids", JSON.stringify(userIds))
}
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
if (bulkFile) form.append("file", bulkFile)
await sendBulkPush(form)
}
toast.success("Notification scheduled", {
description: bulkScheduledAt
? "Notification has been scheduled successfully."
: "Notification has been sent successfully.",
})
setBulkTitle("")
setBulkMessage("")
setBulkRole("")
setBulkUserIds("")
setBulkScheduledAt("")
setBulkFile(null)
setBulkChannel("sms")
setBulkOpen(false)
} catch (err: any) {
const msg =
err?.response?.data?.message ||
"Failed to send notification. Please try again."
toast.error("Failed to send notification", { description: msg })
} finally {
setBulkSending(false)
}
}}
>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)]">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Channel
</label>
<Select
value={bulkChannel}
onChange={(e) => setBulkChannel(e.target.value as typeof bulkChannel)}
>
<option value="sms">Bulk SMS</option>
<option value="email">Bulk email</option>
<option value="push">Bulk push</option>
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
{bulkChannel === "email" ? "Subject" : "Title (push only)"}
</label>
<Input
placeholder={
bulkChannel === "email"
? `e.g. "System Update"`
: `e.g. "System Update"`
}
value={bulkTitle}
onChange={(e) => setBulkTitle(e.target.value)}
/>
</div>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Message
</label>
<Textarea
rows={3}
placeholder={
bulkChannel === "sms"
? "Text body to send by SMS."
: "Notification body for email or push."
}
value={bulkMessage}
onChange={(e) => setBulkMessage(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Role (optional)
</label>
<Input
placeholder='e.g. "student"'
value={bulkRole}
onChange={(e) => setBulkRole(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
User IDs (comma separated)
</label>
<Input
placeholder="e.g. 1,2,3"
value={bulkUserIds}
onChange={(e) => setBulkUserIds(e.target.value)}
/>
</div>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
File attachment (optional)
</label>
<FileUpload
accept="image/*"
onFileSelect={setBulkFile}
label="Upload image or file"
description="Optional image or asset to attach"
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
<div className="space-y-2">
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Scheduled at (optional)
</label>
<Input
type="datetime-local"
value={bulkScheduledAt}
onChange={(e) => setBulkScheduledAt(e.target.value)}
/>
<p className="text-[11px] text-grayScale-400">
Leave empty to send immediately. When set, the notification is stored in{" "}
<code>scheduled_notifications</code> and sent at the specified time.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setBulkTitle("")
setBulkMessage("")
setBulkRole("")
setBulkUserIds("")
setBulkScheduledAt("")
setBulkFile(null)
setBulkChannel("sms")
setBulkOpen(false)
}}
disabled={bulkSending}
>
Cancel
</Button>
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
{bulkSending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending
</>
) : (
<>
<MailOpen className="mr-2 h-3.5 w-3.5" />
Send
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -1,104 +1,337 @@
import { useState } from "react"
import { ArrowLeft } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { ArrowLeft, Loader2, Search, X, Check } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
const permissions = [
"View Dashboard",
"Manage Users",
"Manage Roles",
"Manage Practices",
"View Reports",
"Manage Content",
"Manage Settings",
]
import { Badge } from "../../components/ui/badge"
import { createRole, setRolePermissions, getAllPermissions } from "../../api/rbac.api"
import type { RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
export function AddRolePage() {
const navigate = useNavigate()
const [roleName, setRoleName] = useState("")
const [roleDescription, setRoleDescription] = useState("")
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([])
const [selectedPermissionIds, setSelectedPermissionIds] = useState<Set<number>>(new Set())
const togglePermission = (permission: string) => {
setSelectedPermissions((prev) =>
prev.includes(permission)
? prev.filter((p) => p !== permission)
: [...prev, permission],
)
// Permissions from API (already grouped by group_name)
const [permissionsMap, setPermissionsMap] = useState<Record<string, RolePermission[]>>({})
const [permLoading, setPermLoading] = useState(true)
const [permSearch, setPermSearch] = useState("")
const [saving, setSaving] = useState(false)
// Load all available permissions
useEffect(() => {
const fetch = async () => {
setPermLoading(true)
try {
const res = await getAllPermissions()
setPermissionsMap(res.data.data ?? {})
} catch {
toast.error("Failed to load permissions.")
} finally {
setPermLoading(false)
}
}
fetch()
}, [])
// Flat list of all permissions (for select-all / count)
const allPermissions = useMemo(
() => Object.values(permissionsMap).flat(),
[permissionsMap],
)
// Filtered & sorted groups
const permissionGroups = useMemo(() => {
const q = permSearch.toLowerCase()
const entries: [string, RolePermission[]][] = []
for (const [groupName, perms] of Object.entries(permissionsMap)) {
const filtered = q
? perms.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.key.toLowerCase().includes(q) ||
groupName.toLowerCase().includes(q),
)
: perms
if (filtered.length > 0) entries.push([groupName, filtered])
}
return entries.sort(([a], [b]) => a.localeCompare(b))
}, [permissionsMap, permSearch])
const togglePermission = (id: number) => {
setSelectedPermissionIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleSubmit = () => {
console.log("Add role:", { roleName, roleDescription, selectedPermissions })
navigate("/roles")
const toggleGroup = (perms: RolePermission[]) => {
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
setSelectedPermissionIds((prev) => {
const next = new Set(prev)
for (const p of perms) {
if (allSelected) next.delete(p.id)
else next.add(p.id)
}
return next
})
}
const selectAll = () => {
setSelectedPermissionIds(new Set(allPermissions.map((p) => p.id)))
}
const clearAll = () => {
setSelectedPermissionIds(new Set())
}
const handleSubmit = async () => {
if (!roleName.trim()) {
toast.error("Role name is required.")
return
}
setSaving(true)
try {
// 1. Create the role
const res = await createRole({
name: roleName.trim(),
description: roleDescription.trim(),
})
const newRoleId = res.data.data.id
// 2. Assign permissions if any selected
if (selectedPermissionIds.size > 0) {
await setRolePermissions(newRoleId, {
permission_ids: Array.from(selectedPermissionIds),
})
}
toast.success(`Role "${res.data.data.name}" created successfully.`)
navigate("/roles")
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Failed to create role."
toast.error(message)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/roles")} className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold text-grayScale-900">Add New Role</h1>
<div>
<h1 className="text-xl font-semibold text-grayScale-700">Add New Role</h1>
<p className="text-xs text-grayScale-400">Create a role and assign permissions.</p>
</div>
</div>
<Card className="p-6">
<div className="space-y-6">
<div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">Role Name</label>
<Input
value={roleName}
onChange={(e) => setRoleName(e.target.value)}
placeholder="Enter role name"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-grayScale-700">
Role Description
</label>
<Textarea
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
placeholder="Enter role description"
rows={3}
required
/>
</div>
<div>
<label className="mb-4 block text-sm font-medium text-grayScale-700">
Permissions
</label>
<div className="space-y-2">
{permissions.map((permission) => (
<label
key={permission}
className="flex items-center gap-2 rounded-lg border p-3 hover:bg-grayScale-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedPermissions.includes(permission)}
onChange={() => togglePermission(permission)}
className="h-4 w-4 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500"
/>
<span className="text-sm text-grayScale-700">{permission}</span>
</label>
))}
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* Left Role info */}
<Card className="h-fit shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-sm font-semibold text-grayScale-600">
Role Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
Role Name
</label>
<Input
value={roleName}
onChange={(e) => setRoleName(e.target.value)}
placeholder="e.g. CONTENT_MANAGER"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
Description
</label>
<Textarea
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
placeholder="Describe what this role can do…"
rows={3}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSubmit} className="bg-brand-500 hover:bg-brand-600">
Add Role
<div className="border-t border-grayScale-100 pt-4">
<div className="flex items-center justify-between text-xs text-grayScale-400">
<span>{selectedPermissionIds.size} permission{selectedPermissionIds.size !== 1 ? "s" : ""} selected</span>
<span>{allPermissions.length} available</span>
</div>
</div>
<Button
onClick={handleSubmit}
disabled={saving || !roleName.trim()}
className="w-full bg-brand-500 hover:bg-brand-600"
>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
{saving ? "Creating…" : "Create Role"}
</Button>
</div>
</div>
</Card>
</CardContent>
</Card>
{/* Right Permissions picker */}
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm font-semibold text-grayScale-600">
Permissions
</CardTitle>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-[11px]"
onClick={selectAll}
>
Select all
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-[11px]"
onClick={clearAll}
disabled={selectedPermissionIds.size === 0}
>
Clear
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-4 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
value={permSearch}
onChange={(e) => setPermSearch(e.target.value)}
placeholder="Filter permissions…"
className="pl-9"
/>
{permSearch && (
<button
type="button"
onClick={() => setPermSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Loading */}
{permLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
</div>
)}
{/* Permission groups */}
{!permLoading && (
<div className="max-h-[500px] space-y-5 overflow-y-auto pr-1">
{permissionGroups.length === 0 ? (
<p className="py-8 text-center text-xs text-grayScale-400">
{permSearch ? "No permissions match your search." : "No permissions available."}
</p>
) : (
permissionGroups.map(([groupName, perms]) => {
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
const someSelected = perms.some((p) => selectedPermissionIds.has(p.id))
return (
<div key={groupName}>
{/* Group header */}
<div className="mb-2 flex items-center gap-2">
<button
type="button"
onClick={() => toggleGroup(perms)}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
allSelected
? "border-brand-500 bg-brand-500 text-white"
: someSelected
? "border-brand-300 bg-brand-50"
: "border-grayScale-300",
)}
>
{allSelected && <Check className="h-3 w-3" />}
{someSelected && !allSelected && (
<div className="h-1.5 w-1.5 rounded-sm bg-brand-500" />
)}
</button>
<span className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">
{groupName}
</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{perms.filter((p) => selectedPermissionIds.has(p.id)).length}/{perms.length}
</Badge>
</div>
{/* Permission items */}
<div className="ml-6 grid gap-1">
{perms.map((perm) => {
const isSelected = selectedPermissionIds.has(perm.id)
return (
<label
key={perm.id}
className={cn(
"flex cursor-pointer items-center gap-2.5 rounded-lg border px-3 py-2 transition-colors",
isSelected
? "border-brand-200 bg-brand-50/50"
: "border-grayScale-100 hover:bg-grayScale-50",
)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => togglePermission(perm.id)}
className="h-3.5 w-3.5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-grayScale-700">
{perm.name}
</p>
<p className="truncate text-[10px] text-grayScale-400">
{perm.key}
</p>
</div>
</label>
)
})}
</div>
</div>
)
})
)}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -1,19 +1,110 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Plus, Edit } from "lucide-react"
import {
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
Loader2, AlertCircle, Eye, X,
} from "lucide-react"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
const mockRoles = [
{ id: "1", name: "Admin", userCount: 10 },
{ id: "2", name: "User", userCount: 5 },
]
import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input"
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "../../components/ui/dialog"
import { getRoles, getRoleDetail } from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
export function RolesListPage() {
const navigate = useNavigate()
// List state
const [roles, setRoles] = useState<Role[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Detail modal state
const [selectedRole, setSelectedRole] = useState<RoleDetail | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [query])
// Fetch roles
useEffect(() => {
const fetchRoles = async () => {
setLoading(true)
setError(null)
try {
const res = await getRoles({
query: debouncedQuery || undefined,
page,
page_size: pageSize,
})
setRoles(res.data.data.roles ?? [])
setTotal(res.data.data.total ?? 0)
} catch {
setError("Failed to load roles.")
} finally {
setLoading(false)
}
}
fetchRoles()
}, [debouncedQuery, page, pageSize])
// Open role detail
const handleViewRole = async (roleId: number) => {
setDetailOpen(true)
setDetailLoading(true)
setSelectedRole(null)
try {
const res = await getRoleDetail(roleId)
setSelectedRole(res.data.data)
} catch {
toast.error("Failed to load role details.")
setDetailOpen(false)
} finally {
setDetailLoading(false)
}
}
// Group permissions by group_name
const permissionGroups = useMemo(() => {
if (!selectedRole?.permissions) return []
const map = new Map<string, RolePermission[]>()
for (const p of selectedRole.permissions) {
const group = map.get(p.group_name) ?? []
group.push(p)
map.set(p.group_name, group)
}
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [selectedRole])
const totalPages = Math.max(1, Math.ceil(total / pageSize))
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-grayScale-900">Role Management</h1>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Role Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
Manage roles and their permissions.
</p>
</div>
<Button
onClick={() => navigate("/roles/add")}
className="bg-brand-500 hover:bg-brand-600"
@ -23,22 +114,226 @@ export function RolesListPage() {
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{mockRoles.map((role) => (
<Card key={role.id} className="overflow-hidden shadow-sm">
<div className="h-2 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardContent className="p-6">
<h3 className="mb-2 text-lg font-semibold text-grayScale-900">{role.name}</h3>
<p className="mb-4 text-sm text-grayScale-600">{role.userCount} Users</p>
<Button variant="outline" className="w-full">
<Edit className="mr-2 h-4 w-4" />
Edit Role
</Button>
</CardContent>
</Card>
))}
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search roles…"
className="pl-9"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-5 py-4">
<AlertCircle className="h-5 w-5 shrink-0 text-red-500" />
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
)}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
</div>
)}
{/* Roles grid */}
{!loading && !error && (
<>
{roles.length === 0 ? (
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/60">
<CardContent className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<Shield className="h-10 w-10 text-grayScale-300" />
<p className="text-sm font-semibold text-grayScale-600">No roles found.</p>
<p className="text-xs text-grayScale-400">
{debouncedQuery
? `No roles match "${debouncedQuery}".`
: "Create a new role to get started."}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{roles.map((role) => (
<Card
key={role.id}
className="overflow-hidden shadow-sm transition-shadow hover:shadow-md"
>
<div
className={cn(
"h-1.5",
role.is_system
? "bg-gradient-to-r from-amber-400 to-amber-500"
: "bg-gradient-to-r from-brand-500 to-brand-600",
)}
/>
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg",
role.is_system
? "bg-amber-50 text-amber-600"
: "bg-brand-50 text-brand-600",
)}
>
{role.is_system ? (
<ShieldCheck className="h-4.5 w-4.5" />
) : (
<Shield className="h-4.5 w-4.5" />
)}
</div>
<div>
<h3 className="text-sm font-semibold text-grayScale-700">{role.name}</h3>
<p className="mt-0.5 text-xs text-grayScale-400 line-clamp-1">
{role.description}
</p>
</div>
</div>
{role.is_system && (
<Badge variant="warning" className="shrink-0 text-[10px]">
System
</Badge>
)}
</div>
<div className="mt-4 flex items-center justify-between">
<span className="text-[11px] text-grayScale-400">
Created {new Date(role.created_at).toLocaleDateString()}
</span>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => handleViewRole(role.id)}
>
<Eye className="h-3.5 w-3.5" />
View
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
<p className="text-xs text-grayScale-400">
Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, total)} of {total} roles
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-xs font-medium text-grayScale-600">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
{/* Role detail dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedRole?.is_system ? (
<ShieldCheck className="h-5 w-5 text-amber-500" />
) : (
<Shield className="h-5 w-5 text-brand-500" />
)}
{selectedRole?.name ?? "Role Details"}
</DialogTitle>
<DialogDescription>
{selectedRole?.description}
</DialogDescription>
</DialogHeader>
{detailLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
</div>
)}
{!detailLoading && selectedRole && (
<div className="space-y-5">
{/* Meta row */}
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-400">
{selectedRole.is_system && (
<Badge variant="warning" className="text-[10px]">System Role</Badge>
)}
<span>
Created {new Date(selectedRole.created_at).toLocaleDateString()}
</span>
<span>
{selectedRole.permissions.length} permission{selectedRole.permissions.length !== 1 ? "s" : ""}
</span>
</div>
{/* Permissions grouped */}
<div>
<h4 className="mb-3 text-sm font-semibold text-grayScale-600">Permissions</h4>
{permissionGroups.length === 0 ? (
<p className="text-xs italic text-grayScale-400">No permissions assigned.</p>
) : (
<div className="space-y-4">
{permissionGroups.map(([groupName, perms]) => (
<div key={groupName}>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
{groupName}
</p>
<div className="flex flex-wrap gap-1.5">
{perms.map((p) => (
<span
key={p.id}
title={`${p.key}${p.description}`}
className="inline-flex items-center rounded-md border border-grayScale-200 bg-grayScale-50 px-2 py-0.5 text-[11px] font-medium text-grayScale-600"
>
{p.name}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -26,14 +26,19 @@ export function UsersListPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
const [countryFilter, setCountryFilter] = useState("")
const [regionFilter, setRegionFilter] = useState("")
const [subscriptionFilter, setSubscriptionFilter] = useState("")
const [roleFilter, setRoleFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("")
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await getUsers(page, pageSize)
const res = await getUsers(
page,
pageSize,
roleFilter || undefined,
statusFilter || undefined,
search || undefined,
)
const apiUsers = res.data.data.users
const mapped = apiUsers.map(mapUserApiToUser)
@ -53,7 +58,7 @@ export function UsersListPage() {
}
fetchUsers()
}, [page, pageSize, setUsers, setTotal])
}, [page, pageSize, roleFilter, statusFilter, search, setUsers, setTotal])
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const safePage = Math.min(page, pageCount)
@ -134,45 +139,27 @@ export function UsersListPage() {
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-full sm:w-auto">
<select
value={countryFilter}
onChange={(e) => setCountryFilter(e.target.value)}
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">Country</option>
<option value="USA">USA</option>
<option value="UK">UK</option>
<option value="Canada">Canada</option>
<option value="">All roles</option>
<option value="STUDENT">Student</option>
<option value="TEACHER">Teacher</option>
<option value="ADMIN">Admin</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
<div className="relative w-full sm:w-auto">
<select
value={regionFilter}
onChange={(e) => setRegionFilter(e.target.value)}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">Region</option>
<option value="North">North</option>
<option value="South">South</option>
<option value="East">East</option>
<option value="West">West</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>
<div className="relative w-full sm:w-auto">
<select
value={subscriptionFilter}
onChange={(e) => setSubscriptionFilter(e.target.value)}
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">Subscription</option>
<option value="Monthly">Monthly</option>
<option value="Free">Free</option>
<option value="3-Month">3-Month</option>
<option value="6-Month">6-Month</option>
<option value="Expired">Expired</option>
<option value="">All statuses</option>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div>

View File

@ -439,3 +439,65 @@ export interface CreateQuestionSetResponse {
status_code: number
metadata: unknown
}
// Sub-course Prerequisites
export interface SubCoursePrerequisite {
id: number
sub_course_id: number
prerequisite_sub_course_id: number
prerequisite_title: string
prerequisite_level: string
prerequisite_display_order: number
}
export interface GetSubCoursePrerequisitesResponse {
message: string
data: SubCoursePrerequisite[]
success: boolean
status_code: number
metadata: unknown
}
export interface AddSubCoursePrerequisiteRequest {
prerequisite_sub_course_id: number
}
// Learning Path (full tree from GET /courses/:courseId/learning-path)
export interface LearningPathSubCourse {
id: number
title: string
description: string
thumbnail: string
display_order: number
level: string
prerequisite_count: number
video_count: number
practice_count: number
prerequisites: { sub_course_id: number; title: string; level: string }[]
videos: unknown[]
practices: unknown[]
}
export interface LearningPath {
course_id: number
course_title: string
description: string
thumbnail: string
intro_video_url: string
category_id: number
category_name: string
sub_courses: LearningPathSubCourse[]
}
export interface GetLearningPathResponse {
message: string
data: LearningPath
success: boolean
status_code: number
metadata: unknown
}
export interface ReorderItem {
sub_course_id: number
display_order: number
}

View File

@ -32,6 +32,21 @@ export interface GetIssueResponse {
metadata: null;
}
export interface CreateIssueRequest {
subject: string;
description: string;
issue_type: string;
metadata?: Record<string, unknown>;
}
export interface CreateIssueResponse {
message: string;
data: Issue;
success: boolean;
status_code: number;
metadata: unknown;
}
export interface UpdateIssueStatusResponse {
message: string;
success: boolean;

73
src/types/rbac.types.ts Normal file
View File

@ -0,0 +1,73 @@
export interface Role {
id: number
name: string
description: string
is_system: boolean
created_at: string
}
export interface RolePermission {
id: number
key: string
name: string
description: string
group_name: string
created_at: string
}
export interface RoleDetail extends Role {
permissions: RolePermission[]
}
export interface GetRolesResponse {
message: string
data: {
roles: Role[]
total: number
page: number
page_size: number
}
success: boolean
status_code: number
metadata: unknown
}
export interface GetRoleDetailResponse {
message: string
data: RoleDetail
success: boolean
status_code: number
metadata: unknown
}
export interface GetRolesParams {
query?: string
is_system?: boolean
page?: number
page_size?: number
}
export interface CreateRoleRequest {
name: string
description: string
}
export interface CreateRoleResponse {
message: string
data: Role
success: boolean
status_code: number
metadata: unknown
}
export interface SetRolePermissionsRequest {
permission_ids: number[]
}
export interface GetPermissionsResponse {
message: string
data: Record<string, RolePermission[]>
success: boolean
status_code: number
metadata: unknown
}