From 6a201a010814603d463c12bfc81d97c127d83ea9 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 6 Mar 2026 06:02:02 -0800 Subject: [PATCH] minor fixes --- src/api/courses.api.ts | 13 + src/api/rbac.api.ts | 3 + src/api/team.api.ts | 3 + src/api/users.api.ts | 8 +- src/pages/DashboardPage.tsx | 102 +++ src/pages/ProfilePage.tsx | 838 ++++++++++++------ src/pages/SettingsPage.tsx | 12 +- src/pages/content-management/CoursesPage.tsx | 149 +++- .../SubCourseContentPage.tsx | 344 ++++++- src/pages/role-management/RolesListPage.tsx | 432 ++++++++- src/pages/team/TeamManagementPage.tsx | 89 +- src/pages/team/TeamMemberDetailPage.tsx | 375 ++++---- .../UserManagementDashboard.tsx | 35 +- src/types/course.types.ts | 55 ++ src/types/user.types.ts | 32 + 15 files changed, 1958 insertions(+), 532 deletions(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 6aba8db..57fc5a2 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -42,6 +42,9 @@ import type { AddSubCoursePrerequisiteRequest, GetLearningPathResponse, ReorderItem, + GetRatingsResponse, + GetRatingsParams, + GetVimeoSampleResponse, } from "../types/course.types" export const getCourseCategories = () => @@ -216,3 +219,13 @@ export const getLearningPath = (courseId: number) => export const reorderSubCourses = (courseId: number, items: ReorderItem[]) => http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items }) + +// Ratings +export const getRatings = (params: GetRatingsParams) => + http.get("/ratings", { params }) + +// Vimeo Sample Video +export const getVimeoSample = (videoId: string, width = 640, height = 360) => + http.get("/vimeo/sample", { + params: { video_id: videoId, width, height }, + }) diff --git a/src/api/rbac.api.ts b/src/api/rbac.api.ts index dc5218e..160fc18 100644 --- a/src/api/rbac.api.ts +++ b/src/api/rbac.api.ts @@ -18,6 +18,9 @@ export const getRoleDetail = (roleId: number) => export const createRole = (data: CreateRoleRequest) => http.post("/rbac/roles", data) +export const updateRole = (roleId: number, data: CreateRoleRequest) => + http.put(`/rbac/roles/${roleId}`, data) + export const setRolePermissions = (roleId: number, data: SetRolePermissionsRequest) => http.put(`/rbac/roles/${roleId}/permissions`, data) diff --git a/src/api/team.api.ts b/src/api/team.api.ts index c0b3d8f..6780890 100644 --- a/src/api/team.api.ts +++ b/src/api/team.api.ts @@ -14,3 +14,6 @@ export const getTeamMemberById = (id: number) => export const createTeamMember = (data: CreateTeamMemberRequest) => http.post("/team/register", data) + +export const updateTeamMemberStatus = (id: number, status: string) => + http.patch(`/team/members/${id}/status`, { status }) diff --git a/src/api/users.api.ts b/src/api/users.api.ts index 394a516..2b4d12e 100644 --- a/src/api/users.api.ts +++ b/src/api/users.api.ts @@ -1,5 +1,5 @@ import http from "./http"; -import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types"; +import { type UserProfileResponse, type GetUsersResponse, type UpdateProfileRequest, type UserSummaryResponse } from "../types/user.types"; export const getUsers = ( page?: number, @@ -37,3 +37,9 @@ export interface CreateUserRequest { export const createUser = (payload: CreateUserRequest) => http.post("/users", payload); + +export const updateProfile = (data: UpdateProfileRequest) => + http.put("/user", data); + +export const getUserSummary = () => + http.get("/users/summary"); diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index c57038b..33a28a5 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -5,6 +5,8 @@ import { // Coins, DollarSign, HelpCircle, + MessageSquare, + Star, TicketCheck, // TrendingUp, Users, @@ -32,8 +34,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { cn } from "../lib/utils" import { getTeamMemberById } from "../api/team.api" import { getDashboard } from "../api/analytics.api" +import { getRatings } from "../api/courses.api" import { useEffect, useState } from "react" import type { DashboardData } from "../types/analytics.types" +import type { Rating } from "../types/course.types" const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"] @@ -47,6 +51,8 @@ export function DashboardPage() { const [dashboard, setDashboard] = useState(null) const [loading, setLoading] = useState(true) const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary") + const [appRatings, setAppRatings] = useState([]) + const [appRatingsLoading, setAppRatingsLoading] = useState(true) useEffect(() => { const fetchUser = async () => { @@ -75,8 +81,20 @@ export function DashboardPage() { } } + const fetchAppRatings = async () => { + try { + const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 }) + setAppRatings(res.data.data) + } catch (err) { + console.error(err) + } finally { + setAppRatingsLoading(false) + } + } + fetchUser() fetchDashboard() + fetchAppRatings() }, []) const registrationData = @@ -409,6 +427,90 @@ export function DashboardPage() { ))} + + {/* App Ratings */} + + +
+ + Recent App Reviews +
+
+ + {appRatingsLoading ? ( +
+ +
+ ) : appRatings.length === 0 ? ( +
+ No app reviews yet +
+ ) : ( + <> +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + sum + r.stars, 0) / appRatings.length, + ) + ? "fill-amber-400 text-amber-400" + : "fill-grayScale-200 text-grayScale-200", + )} + /> + ))} +
+ + {(appRatings.reduce((sum, r) => sum + r.stars, 0) / appRatings.length).toFixed(1)} + + + ({appRatings.length} {appRatings.length === 1 ? "review" : "reviews"}) + +
+ +
+ {appRatings.map((rating) => ( +
+
+ U{rating.user_id} +
+
+
+ + User #{rating.user_id} + + + {formatDate(rating.created_at)} + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ {rating.review && ( +

{rating.review}

+ )} +
+
+ ))} +
+ + )} +
+
)} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 933fa24..c6ebbd2 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -4,23 +4,33 @@ import { CheckCircle2, Clock, Globe, - // GraduationCap, - Languages, + Loader2, Mail, MapPin, + Pencil, Phone, + Save, Shield, User, + X, XCircle, Briefcase, - // RefreshCw, + BookOpen, + Target, + Languages, + Heart, + MessageCircle, } from "lucide-react"; import { Badge } from "../components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; -import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; +import { Button } from "../components/ui/button"; +import { Card, CardContent } from "../components/ui/card"; +import { Input } from "../components/ui/input"; +import { Select } from "../components/ui/select"; + import { cn } from "../lib/utils"; -import { getMyProfile } from "../api/users.api"; -import type { UserProfileData } from "../types/user.types"; +import { getMyProfile, updateProfile } from "../api/users.api"; +import type { UserProfileData, UpdateProfileRequest } from "../types/user.types"; +import { toast } from "sonner"; function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return "—"; @@ -44,11 +54,10 @@ function formatDateTime(dateStr: string | null | undefined): string { function LoadingSkeleton() { return ( -
+
- {/* Hero skeleton */}
-
+
@@ -60,7 +69,6 @@ function LoadingSkeleton() {
- {/* Info cards skeleton */}
{[1, 2, 3].map((i) => (
@@ -81,33 +89,6 @@ function LoadingSkeleton() { ); } -function InfoRow({ - icon: Icon, - label, - value, - extra, -}: { - icon: typeof User; - label: string; - value: string; - extra?: React.ReactNode; -}) { - return ( -
-
-
- -
- {label} -
-
- {value || "—"} - {extra} -
-
- ); -} - function VerifiedIcon({ verified }: { verified: boolean }) { return verified ? (
@@ -121,20 +102,20 @@ function VerifiedIcon({ verified }: { verified: boolean }) { } function ProgressRing({ percent }: { percent: number }) { - const radius = 14; + const radius = 18; const circumference = 2 * Math.PI * radius; const offset = circumference - (percent / 100) * circumference; return (
- + - {percent}% + {percent}% +
+ ); +} + +function DetailItem({ + icon: Icon, + label, + value, + extra, + editNode, + editing, +}: { + icon: typeof User; + label: string; + value: string; + extra?: React.ReactNode; + editNode?: React.ReactNode; + editing?: boolean; +}) { + return ( +
+
+ +
+
+

+ {label} +

+ {editing && editNode ? ( +
{editNode}
+ ) : ( +
+

{value || "—"}

+ {extra} +
+ )} +
); } @@ -159,6 +177,9 @@ export function ProfilePage() { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [editForm, setEditForm] = useState({}); useEffect(() => { const fetchProfile = async () => { @@ -175,11 +196,59 @@ export function ProfilePage() { fetchProfile(); }, []); + const startEditing = () => { + if (!profile) return; + setEditForm({ + first_name: profile.first_name ?? "", + last_name: profile.last_name ?? "", + nick_name: profile.nick_name ?? "", + gender: profile.gender ?? "", + birth_day: profile.birth_day ?? "", + age_group: profile.age_group ?? "", + education_level: profile.education_level ?? "", + country: profile.country ?? "", + region: profile.region ?? "", + occupation: profile.occupation ?? "", + learning_goal: profile.learning_goal ?? "", + language_goal: profile.language_goal ?? "", + language_challange: profile.language_challange ?? "", + favoutite_topic: profile.favoutite_topic ?? "", + preferred_language: profile.preferred_language ?? "", + }); + setEditing(true); + }; + + const cancelEditing = () => { + setEditing(false); + setEditForm({}); + }; + + const handleSave = async () => { + setSaving(true); + try { + await updateProfile(editForm); + const res = await getMyProfile(); + setProfile(res.data.data); + setEditing(false); + setEditForm({}); + toast.success("Profile updated successfully"); + } catch (err) { + console.error("Failed to update profile", err); + toast.error("Failed to update profile. Please try again."); + } finally { + setSaving(false); + } + }; + + const updateField = (field: keyof UpdateProfileRequest, value: string) => { + setEditForm((prev) => ({ ...prev, [field]: value })); + }; + if (loading) return ; if (error || !profile) { return ( -
+
@@ -200,250 +269,483 @@ export function ProfilePage() { } const fullName = `${profile.first_name} ${profile.last_name}`; - const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase(); const completionPct = profile.profile_completion_percentage ?? 0; return ( -
- {/* Page header (no tabs) */} -
-

My Info

-

Profile

-
+
+ {/* ─── Hero Card ─── */} +
+ {/* Tall dark gradient banner with content inside */} +
+
- {/* Main profile layout card */} -
- {/* Header strip */} -
-
-
-

Overview

-

- Personal, job and account details for this team member. -

-
+
+

+ Hello {profile.first_name} +

+

+ This is your profile page. You can see the progress you've made with your work and manage your projects or assigned tasks +

+
+ +
+ {!editing ? ( + + ) : ( +
+ + +
+ )}
-
-
- {/* Left column: About & details */} -
- {/* Identity */} -
- - - - {initials} - - -
-
-

{fullName}

- - #{profile.id} - -
-
- - - {profile.role} - - - - Joined {formatDate(profile.created_at)} - -
-
- - - {profile.status} - -
- - Profile complete -
-
-
-
+ {/* Identity info below banner */} +
+ {editing ? ( +
+ updateField("first_name", e.target.value)} + placeholder="First name" + /> + updateField("last_name", e.target.value)} + placeholder="Last name" + /> + + #{profile.id} + +
+ ) : ( +
+

+ {fullName} +

+ {profile.nick_name && ( + + @{profile.nick_name} + + )} + + #{profile.id} + +
+ )} - {/* About / Contact */} -
-

- About -

-
- } /> - } /> - + + + {profile.role} + + + + {profile.status} + + + + Joined {formatDate(profile.created_at)} + +
+
+
+ + {/* ─── Detail Cards Grid ─── */} +
+ {/* ── Contact & Personal ── */} + +
+ +
+ {/* Contact */} +
+

+ Contact +

+
+ } + /> + } + /> + + updateField("region", e.target.value)} + placeholder="Region" + /> + updateField("country", e.target.value)} + placeholder="Country" + /> +
+ } + /> + updateField("preferred_language", e.target.value)} + > + + + + + + + } />
- {/* Employee details */} -
-

- Employee details -

-
-
-
Date of birth
-
- {formatDate(profile.birth_day)} -
-
-
-
Age
-
- {profile.age ? `${profile.age} years` : "—"} -
-
-
-
Gender
-
- {profile.gender || "Not specified"} -
-
-
-
Age group
-
- {profile.age_group || "—"} -
-
-
-
Occupation
-
- {profile.occupation || "—"} -
-
-
-
Preferred language
-
- {profile.preferred_language || "—"} -
-
-
-
-
- - {/* Right column: Activity & account summary */} -
- {/* Activity */} -
-

- Activity -

- - -
-
-
- -
-
-

- Last login -

-

- {formatDateTime(profile.last_login)} -

-
-
-
-
-
- -
-
-

- Account created -

-

- {formatDateTime(profile.created_at)} -

-
-
-
-
-
- - {/* Account summary */} -
-

- Account -

- - -
- Role - {profile.role} -
-
- Status - +

+ Personal +

+
+ updateField("birth_day", e.target.value)} + /> + } + /> + updateField("gender", e.target.value)} > - - {profile.status} - -
-
- Email - - - {profile.email} - - - -
-
- Phone - - - {profile.phone_number || "—"} - - - -
- - + + + + + + } + /> + updateField("age_group", e.target.value)} + > + + + + + + + + + } + /> + updateField("occupation", e.target.value)} + placeholder="Occupation" + /> + } + /> + updateField("education_level", e.target.value)} + placeholder="Education level" + /> + } + /> +
-
+ + + + {/* ── Right Sidebar ── */} +
+ {/* Profile Completion */} + +
+ + +
+

Profile Completion

+

+ {completionPct === 100 ? "All set!" : "Complete your profile for the best experience."} +

+
+
+ + + {/* Activity */} + +
+ +

+ Activity +

+
+
+ +
+
+

Last Login

+

{formatDateTime(profile.last_login)}

+
+
+
+
+ +
+
+

Account Created

+

{formatDateTime(profile.created_at)}

+
+
+
+ + + {/* Quick Account Info */} + +
+ +

+ Account +

+
+
+ Role + + {profile.role} + +
+
+ Status + + + {profile.status} + +
+
+ Email + + + {profile.email} + + + +
+
+ Phone + + + {profile.phone_number || "—"} + + + +
+
+
+
+ + {/* ─── Learning & Goals Card ─── */} + +
+ +
+
+ updateField("learning_goal", e.target.value)} + placeholder="Your learning goal" + /> + } + /> +
+
+ updateField("language_goal", e.target.value)} + placeholder="Language goal" + /> + } + /> +
+
+ updateField("language_challange", e.target.value)} + placeholder="Language challenge" + /> + } + /> +
+
+ updateField("favoutite_topic", e.target.value)} + placeholder="Favourite topic" + /> + } + /> +
+
+
+
); } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index c90ad59..8de3147 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -21,7 +21,7 @@ import { Button } from "../components/ui/button"; import { Select } from "../components/ui/select"; import { Separator } from "../components/ui/separator"; import { cn } from "../lib/utils"; -import { getMyProfile } from "../api/users.api"; +import { getMyProfile, updateProfile } from "../api/users.api"; import type { UserProfileData } from "../types/user.types"; import { toast } from "sonner"; @@ -127,9 +127,15 @@ function ProfileTab({ profile }: { profile: UserProfileData }) { const handleSave = async () => { setSaving(true); try { - // placeholder — wire up to API when endpoint is ready - await new Promise((r) => setTimeout(r, 600)); + await updateProfile({ + first_name: firstName, + last_name: lastName, + nick_name: nickName, + preferred_language: language, + }); toast.success("Profile settings saved"); + } catch { + toast.error("Failed to save profile settings."); } finally { setSaving(false); } diff --git a/src/pages/content-management/CoursesPage.tsx b/src/pages/content-management/CoursesPage.tsx index f68b337..c410989 100644 --- a/src/pages/content-management/CoursesPage.tsx +++ b/src/pages/content-management/CoursesPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" import { Link, useParams, useNavigate } from "react-router-dom" -import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle } from "lucide-react" +import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare } from "lucide-react" import practiceSrc from "../../assets/Practice.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" @@ -16,8 +16,8 @@ import { 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" +import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse, getRatings } from "../../api/courses.api" +import type { Course, CourseCategory, Rating } from "../../types/course.types" export function CoursesPage() { const { categoryId } = useParams<{ categoryId: string }>() @@ -43,6 +43,10 @@ export function CoursesPage() { const [editThumbnail, setEditThumbnail] = useState("") const [updating, setUpdating] = useState(false) const [updateError, setUpdateError] = useState(null) + const [showRatingsModal, setShowRatingsModal] = useState(false) + const [ratingsCourseId, setRatingsCourseId] = useState(null) + const [courseRatings, setCourseRatings] = useState([]) + const [courseRatingsLoading, setCourseRatingsLoading] = useState(false) const fetchCourses = async () => { if (!categoryId) return @@ -212,6 +216,20 @@ export function CoursesPage() { navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`) } + const handleViewRatings = async (courseId: number) => { + setRatingsCourseId(courseId) + setShowRatingsModal(true) + setCourseRatingsLoading(true) + try { + const res = await getRatings({ target_type: "course", target_id: courseId, limit: 10 }) + setCourseRatings(res.data.data ?? []) + } catch (err) { + console.error("Failed to fetch ratings:", err) + } finally { + setCourseRatingsLoading(false) + } + } + if (loading) { return (
@@ -332,6 +350,17 @@ export function CoursesPage() {
+ +
+ +
+ {courseRatingsLoading ? ( +
+
+

Loading ratings…

+
+ ) : courseRatings.length === 0 ? ( +
+
+ +
+

No ratings yet

+

+ Ratings will appear here once learners start reviewing this course. +

+
+ ) : ( +
+ {/* Summary bar */} +
+
+ + + {(courseRatings.reduce((sum, r) => sum + r.stars, 0) / courseRatings.length).toFixed(1)} + + / 5 +
+
+ + {courseRatings.length} review{courseRatings.length !== 1 ? "s" : ""} + +
+ + {/* Rating cards */} +
+ {courseRatings.map((rating) => ( +
+ {/* Header row */} +
+
+
+ U{rating.user_id} +
+
+

User #{rating.user_id}

+

+ {new Date(rating.created_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + {rating.updated_at !== rating.created_at && ( + · edited + )} +

+
+
+ + {/* Stars */} +
+ {[1, 2, 3, 4, 5].map((s) => ( + + ))} +
+
+ + {/* Review text */} + {rating.review && ( +
+ +

+ {rating.review} +

+
+ )} +
+ ))} +
+
+ )} +
+
+
+ )} + {/* Delete Course Modal */} {showDeleteModal && courseToDelete && (
diff --git a/src/pages/content-management/SubCourseContentPage.tsx b/src/pages/content-management/SubCourseContentPage.tsx index a700b01..bcf5631 100644 --- a/src/pages/content-management/SubCourseContentPage.tsx +++ b/src/pages/content-management/SubCourseContentPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" import { Link, useParams, useNavigate } from "react-router-dom" -import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical } from "lucide-react" +import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play, Loader2 } from "lucide-react" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import { Card } from "../../components/ui/card" import alertSrc from "../../assets/Alert.svg" @@ -15,11 +15,14 @@ import { deleteQuestionSet, createVimeoVideo, updateSubCourseVideo, - deleteSubCourseVideo + deleteSubCourseVideo, + getRatings, + getVimeoSample, } from "../../api/courses.api" -import type { SubCourse, QuestionSet, SubCourseVideo } from "../../types/course.types" +import type { SubCourse, QuestionSet, SubCourseVideo, Rating, VimeoSampleVideo } from "../../types/course.types" +import { Select } from "../../components/ui/select" -type TabType = "video" | "practice" +type TabType = "video" | "practice" | "ratings" type StatusFilter = "all" | "published" | "draft" | "archived" export function SubCourseContentPage() { @@ -62,12 +65,27 @@ export function SubCourseContentPage() { const [deletingVideo, setDeletingVideo] = useState(false) const [openVideoMenuId, setOpenVideoMenuId] = useState(null) + // Ratings state + const [ratings, setRatings] = useState([]) + const [ratingsLoading, setRatingsLoading] = useState(false) + const [ratingsPage, setRatingsPage] = useState(0) + const [ratingsPageSize] = useState(10) + const [videoTitle, setVideoTitle] = useState("") const [videoDescription, setVideoDescription] = useState("") const [videoUrl, setVideoUrl] = useState("") const [videoFileSize, setVideoFileSize] = useState(0) const [videoDuration, setVideoDuration] = useState(0) + // Vimeo preview state + const [showPreviewModal, setShowPreviewModal] = useState(false) + const [previewIframe, setPreviewIframe] = useState("") + const [previewVideo, setPreviewVideo] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [sampleVideoId, setSampleVideoId] = useState("") + const [modalPreviewIframe, setModalPreviewIframe] = useState("") + const [modalPreviewLoading, setModalPreviewLoading] = useState(false) + useEffect(() => { const fetchData = async () => { if (!subCourseId || !courseId) return @@ -115,14 +133,40 @@ export function SubCourseContentPage() { } } + const fetchRatings = async (offset = 0) => { + if (!subCourseId) return + setRatingsLoading(true) + try { + const res = await getRatings({ + target_type: "sub_course", + target_id: Number(subCourseId), + limit: ratingsPageSize, + offset, + }) + setRatings(res.data.data ?? []) + } catch (err) { + console.error("Failed to fetch ratings:", err) + } finally { + setRatingsLoading(false) + } + } + useEffect(() => { if (activeTab === "practice") { fetchPractices() - } else { + } else if (activeTab === "video") { fetchVideos() + } else if (activeTab === "ratings") { + fetchRatings(ratingsPage * ratingsPageSize) } }, [activeTab, subCourseId]) + useEffect(() => { + if (activeTab === "ratings") { + fetchRatings(ratingsPage * ratingsPageSize) + } + }, [ratingsPage]) + const handleAddPractice = () => { navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`) } @@ -277,6 +321,47 @@ export function SubCourseContentPage() { } } + // Preview a video card via Vimeo sample API + const handlePreviewVideo = async (video: SubCourseVideo) => { + const idMatch = video.video_url?.match(/(\d{5,})/) + const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny + setShowPreviewModal(true) + setPreviewLoading(true) + setPreviewIframe("") + setPreviewVideo(null) + try { + const res = await getVimeoSample(vimeoId) + setPreviewIframe(res.data.data.iframe) + setPreviewVideo(res.data.data.video) + } catch { + setPreviewIframe("") + } finally { + setPreviewLoading(false) + } + } + + // Preview inside add/edit modal from a sample vimeo ID picker + const handleModalPreview = async (vimeoId: string) => { + if (!vimeoId) { + setModalPreviewIframe("") + return + } + setModalPreviewLoading(true) + try { + const res = await getVimeoSample(vimeoId) + setModalPreviewIframe(res.data.data.iframe) + // Auto-fill fields from vimeo metadata + const v = res.data.data.video + if (!videoTitle) setVideoTitle(v.name) + if (!videoDescription) setVideoDescription(v.description?.slice(0, 200) ?? "") + if (!videoDuration) setVideoDuration(v.duration) + } catch { + setModalPreviewIframe("") + } finally { + setModalPreviewLoading(false) + } + } + const filteredPractices = practices.filter((practice) => { if (statusFilter === "all") return true if (statusFilter === "published") return practice.status === "PUBLISHED" @@ -374,6 +459,19 @@ export function SubCourseContentPage() { )} +
@@ -569,15 +667,25 @@ export function SubCourseContentPage() { {/* Title */}

{video.title}

- {/* Edit button */} - + {/* Edit / Preview buttons */} +
+ + +
{/* Publish button */} + +
+
+
+ )} + + )} + {/* Delete Modal */} {showDeleteModal && practiceToDelete && (
@@ -690,17 +927,48 @@ export function SubCourseContentPage() { {/* Add Video Modal */} {showAddVideoModal && (
-
+

Add Video

-
+
+ {/* Sample Vimeo picker */} +
+

+ Try a sample Vimeo video +

+
+ + {modalPreviewLoading && } +
+ {modalPreviewIframe && ( +
+ )} +
+
)} + + {/* Video Preview Modal */} + {showPreviewModal && ( +
+
+
+
+

+ {previewVideo?.name ?? "Video Preview"} +

+ {previewVideo && ( +

+ {Math.floor(previewVideo.duration / 60)}:{(previewVideo.duration % 60).toString().padStart(2, "0")} • {previewVideo.width}×{previewVideo.height} +

+ )} +
+ +
+
+ {previewLoading ? ( +
+ +
+ ) : previewIframe ? ( +
+ ) : ( +
+

Failed to load preview.

+
+ )} +
+
+
+ )}
) } diff --git a/src/pages/role-management/RolesListPage.tsx b/src/pages/role-management/RolesListPage.tsx index 1462c21..44e0bbc 100644 --- a/src/pages/role-management/RolesListPage.tsx +++ b/src/pages/role-management/RolesListPage.tsx @@ -2,16 +2,17 @@ import { useEffect, useMemo, useState } from "react" import { useNavigate } from "react-router-dom" import { Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight, - Loader2, AlertCircle, Eye, X, + Loader2, AlertCircle, Eye, X, Pencil, Check, } from "lucide-react" import { Button } from "../../components/ui/button" import { Card, CardContent } from "../../components/ui/card" import { Badge } from "../../components/ui/badge" import { Input } from "../../components/ui/input" +import { Textarea } from "../../components/ui/textarea" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "../../components/ui/dialog" -import { getRoles, getRoleDetail } from "../../api/rbac.api" +import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api" import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types" import { cn } from "../../lib/utils" import { toast } from "sonner" @@ -34,6 +35,20 @@ export function RolesListPage() { const [detailOpen, setDetailOpen] = useState(false) const [detailLoading, setDetailLoading] = useState(false) + // Role info editing state + const [editingRole, setEditingRole] = useState(false) + const [editName, setEditName] = useState("") + const [editDescription, setEditDescription] = useState("") + const [savingRole, setSavingRole] = useState(false) + + // Permissions editing state + const [editingPermissions, setEditingPermissions] = useState(false) + const [allPermissionsMap, setAllPermissionsMap] = useState>({}) + const [permLoading, setPermLoading] = useState(false) + const [selectedPermissionIds, setSelectedPermissionIds] = useState>(new Set()) + const [permSearch, setPermSearch] = useState("") + const [savingPermissions, setSavingPermissions] = useState(false) + // Debounce search query useEffect(() => { const timer = setTimeout(() => { @@ -81,6 +96,105 @@ export function RolesListPage() { } } + // Enter role info edit mode + const handleEditRole = () => { + if (!selectedRole) return + setEditName(selectedRole.name) + setEditDescription(selectedRole.description) + setEditingRole(true) + } + + const handleCancelEditRole = () => { + setEditingRole(false) + } + + const handleSaveRole = async () => { + if (!selectedRole || !editName.trim()) return + setSavingRole(true) + try { + await updateRole(selectedRole.id, { + name: editName.trim(), + description: editDescription.trim(), + }) + const res = await getRoleDetail(selectedRole.id) + setSelectedRole(res.data.data) + setEditingRole(false) + toast.success("Role updated successfully.") + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + "Failed to update role." + toast.error(message) + } finally { + setSavingRole(false) + } + } + + // Enter edit mode – fetch all permissions + const handleEditPermissions = async () => { + setEditingPermissions(true) + setPermSearch("") + setSelectedPermissionIds(new Set(selectedRole?.permissions.map((p) => p.id) ?? [])) + + if (Object.keys(allPermissionsMap).length === 0) { + setPermLoading(true) + try { + const res = await getAllPermissions() + setAllPermissionsMap(res.data.data ?? {}) + } catch { + toast.error("Failed to load permissions.") + setEditingPermissions(false) + } finally { + setPermLoading(false) + } + } + } + + const handleCancelEdit = () => { + setEditingPermissions(false) + setPermSearch("") + } + + 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 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 handleSavePermissions = async () => { + if (!selectedRole) return + setSavingPermissions(true) + try { + await setRolePermissions(selectedRole.id, { + permission_ids: Array.from(selectedPermissionIds), + }) + // Refresh role detail + const res = await getRoleDetail(selectedRole.id) + setSelectedRole(res.data.data) + setEditingPermissions(false) + toast.success("Permissions updated successfully.") + } catch { + toast.error("Failed to update permissions.") + } finally { + setSavingPermissions(false) + } + } + // Group permissions by group_name const permissionGroups = useMemo(() => { if (!selectedRole?.permissions) return [] @@ -93,6 +207,24 @@ export function RolesListPage() { return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)) }, [selectedRole]) + // Filtered permission groups for edit mode + const editPermissionGroups = useMemo(() => { + const q = permSearch.toLowerCase() + const entries: [string, RolePermission[]][] = [] + for (const [groupName, perms] of Object.entries(allPermissionsMap)) { + 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)) + }, [allPermissionsMap, permSearch]) + const totalPages = Math.max(1, Math.ceil(total / pageSize)) return ( @@ -175,7 +307,7 @@ export function RolesListPage() { className={cn( "h-1.5", role.is_system - ? "bg-gradient-to-r from-amber-400 to-amber-500" + ? "bg-gradient-to-r from-brand-400 to-brand-600" : "bg-gradient-to-r from-brand-500 to-brand-600", )} /> @@ -186,7 +318,7 @@ export function RolesListPage() { className={cn( "flex h-9 w-9 items-center justify-center rounded-lg", role.is_system - ? "bg-amber-50 text-amber-600" + ? "bg-brand-100 text-brand-600" : "bg-brand-50 text-brand-600", )} > @@ -265,20 +397,89 @@ export function RolesListPage() { )} {/* Role detail dialog */} - + { + setDetailOpen(open) + if (!open) { + setEditingPermissions(false) + setEditingRole(false) + setPermSearch("") + } + }}> - - {selectedRole?.is_system ? ( - - ) : ( - - )} - {selectedRole?.name ?? "Role Details"} - - - {selectedRole?.description} - + {!editingRole ? ( + <> + + {selectedRole?.is_system ? ( + + ) : ( + + )} + {selectedRole?.name ?? "Role Details"} + {selectedRole && ( + + )} + + + {selectedRole?.description} + + + ) : ( + <> + Edit Role + Update the role name and description. +
+
+ + setEditName(e.target.value)} + placeholder="e.g. CONTENT_MANAGER" + /> +
+
+ +