minor fixes

This commit is contained in:
Yared Yemane 2026-03-06 06:02:02 -08:00
parent 3ecd35f960
commit 6a201a0108
15 changed files with 1958 additions and 532 deletions

View File

@ -42,6 +42,9 @@ import type {
AddSubCoursePrerequisiteRequest, AddSubCoursePrerequisiteRequest,
GetLearningPathResponse, GetLearningPathResponse,
ReorderItem, ReorderItem,
GetRatingsResponse,
GetRatingsParams,
GetVimeoSampleResponse,
} from "../types/course.types" } from "../types/course.types"
export const getCourseCategories = () => export const getCourseCategories = () =>
@ -216,3 +219,13 @@ export const getLearningPath = (courseId: number) =>
export const reorderSubCourses = (courseId: number, items: ReorderItem[]) => export const reorderSubCourses = (courseId: number, items: ReorderItem[]) =>
http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items }) http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items })
// Ratings
export const getRatings = (params: GetRatingsParams) =>
http.get<GetRatingsResponse>("/ratings", { params })
// Vimeo Sample Video
export const getVimeoSample = (videoId: string, width = 640, height = 360) =>
http.get<GetVimeoSampleResponse>("/vimeo/sample", {
params: { video_id: videoId, width, height },
})

View File

@ -18,6 +18,9 @@ export const getRoleDetail = (roleId: number) =>
export const createRole = (data: CreateRoleRequest) => export const createRole = (data: CreateRoleRequest) =>
http.post<CreateRoleResponse>("/rbac/roles", data) http.post<CreateRoleResponse>("/rbac/roles", data)
export const updateRole = (roleId: number, data: CreateRoleRequest) =>
http.put<CreateRoleResponse>(`/rbac/roles/${roleId}`, data)
export const setRolePermissions = (roleId: number, data: SetRolePermissionsRequest) => export const setRolePermissions = (roleId: number, data: SetRolePermissionsRequest) =>
http.put(`/rbac/roles/${roleId}/permissions`, data) http.put(`/rbac/roles/${roleId}/permissions`, data)

View File

@ -14,3 +14,6 @@ export const getTeamMemberById = (id: number) =>
export const createTeamMember = (data: CreateTeamMemberRequest) => export const createTeamMember = (data: CreateTeamMemberRequest) =>
http.post("/team/register", data) http.post("/team/register", data)
export const updateTeamMemberStatus = (id: number, status: string) =>
http.patch(`/team/members/${id}/status`, { status })

View File

@ -1,5 +1,5 @@
import http from "./http"; 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 = ( export const getUsers = (
page?: number, page?: number,
@ -37,3 +37,9 @@ export interface CreateUserRequest {
export const createUser = (payload: CreateUserRequest) => export const createUser = (payload: CreateUserRequest) =>
http.post("/users", payload); http.post("/users", payload);
export const updateProfile = (data: UpdateProfileRequest) =>
http.put<UserProfileResponse>("/user", data);
export const getUserSummary = () =>
http.get<UserSummaryResponse>("/users/summary");

View File

@ -5,6 +5,8 @@ import {
// Coins, // Coins,
DollarSign, DollarSign,
HelpCircle, HelpCircle,
MessageSquare,
Star,
TicketCheck, TicketCheck,
// TrendingUp, // TrendingUp,
Users, Users,
@ -32,8 +34,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api" import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api" import { getDashboard } from "../api/analytics.api"
import { getRatings } from "../api/courses.api"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import type { DashboardData } from "../types/analytics.types" 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"] const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
@ -47,6 +51,8 @@ export function DashboardPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary") const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
const [appRatings, setAppRatings] = useState<Rating[]>([])
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
useEffect(() => { useEffect(() => {
const fetchUser = async () => { 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() fetchUser()
fetchDashboard() fetchDashboard()
fetchAppRatings()
}, []) }, [])
const registrationData = const registrationData =
@ -409,6 +427,90 @@ export function DashboardPage() {
</Card> </Card>
))} ))}
</div> </div>
{/* App Ratings */}
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-brand-500" />
<CardTitle>Recent App Reviews</CardTitle>
</div>
</CardHeader>
<CardContent className="p-6 pt-2">
{appRatingsLoading ? (
<div className="flex items-center justify-center py-10">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
</div>
) : appRatings.length === 0 ? (
<div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
No app reviews yet
</div>
) : (
<>
<div className="mb-4 flex items-center gap-3 rounded-lg bg-grayScale-50 px-4 py-3">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
"h-4 w-4",
i <
Math.round(
appRatings.reduce((sum, r) => sum + r.stars, 0) / appRatings.length,
)
? "fill-amber-400 text-amber-400"
: "fill-grayScale-200 text-grayScale-200",
)}
/>
))}
</div>
<span className="text-sm font-semibold text-grayScale-600">
{(appRatings.reduce((sum, r) => sum + r.stars, 0) / appRatings.length).toFixed(1)}
</span>
<span className="text-xs text-grayScale-400">
({appRatings.length} {appRatings.length === 1 ? "review" : "reviews"})
</span>
</div>
<div className="space-y-4">
{appRatings.map((rating) => (
<div key={rating.id} className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-semibold text-brand-600">
U{rating.user_id}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-grayScale-600">
User #{rating.user_id}
</span>
<span className="shrink-0 text-xs text-grayScale-400">
{formatDate(rating.created_at)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cn(
"h-3.5 w-3.5",
i < rating.stars
? "fill-amber-400 text-amber-400"
: "fill-grayScale-200 text-grayScale-200",
)}
/>
))}
</div>
{rating.review && (
<p className="mt-1 text-sm text-grayScale-500">{rating.review}</p>
)}
</div>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
</div> </div>
</> </>
)} )}

View File

@ -4,23 +4,33 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
Globe, Globe,
// GraduationCap, Loader2,
Languages,
Mail, Mail,
MapPin, MapPin,
Pencil,
Phone, Phone,
Save,
Shield, Shield,
User, User,
X,
XCircle, XCircle,
Briefcase, Briefcase,
// RefreshCw, BookOpen,
Target,
Languages,
Heart,
MessageCircle,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "../components/ui/badge"; import { Badge } from "../components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Button } from "../components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; 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 { 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 type { UserProfileData, UpdateProfileRequest } from "../types/user.types";
import { toast } from "sonner";
function formatDate(dateStr: string | null | undefined): string { function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—"; if (!dateStr) return "—";
@ -44,11 +54,10 @@ function formatDateTime(dateStr: string | null | undefined): string {
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6"> <div className="w-full space-y-8 py-10">
<div className="animate-pulse space-y-8"> <div className="animate-pulse space-y-8">
{/* Hero skeleton */}
<div className="overflow-hidden rounded-2xl border border-grayScale-100"> <div className="overflow-hidden rounded-2xl border border-grayScale-100">
<div className="h-36 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" /> <div className="h-40 bg-gradient-to-r from-grayScale-100 via-grayScale-200/60 to-grayScale-100" />
<div className="flex flex-col items-center px-8 pb-8"> <div className="flex flex-col items-center px-8 pb-8">
<div className="-mt-14 h-28 w-28 rounded-full bg-grayScale-100 ring-4 ring-white" /> <div className="-mt-14 h-28 w-28 rounded-full bg-grayScale-100 ring-4 ring-white" />
<div className="mt-4 h-6 w-48 rounded-lg bg-grayScale-100" /> <div className="mt-4 h-6 w-48 rounded-lg bg-grayScale-100" />
@ -60,7 +69,6 @@ function LoadingSkeleton() {
</div> </div>
</div> </div>
</div> </div>
{/* Info cards skeleton */}
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl border border-grayScale-100 p-6"> <div key={i} className="rounded-2xl border border-grayScale-100 p-6">
@ -81,33 +89,6 @@ function LoadingSkeleton() {
); );
} }
function InfoRow({
icon: Icon,
label,
value,
extra,
}: {
icon: typeof User;
label: string;
value: string;
extra?: React.ReactNode;
}) {
return (
<div className="group flex flex-col gap-1 rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 text-sm text-grayScale-400">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400 transition-colors group-hover:bg-brand-100 group-hover:text-brand-500">
<Icon className="h-4 w-4" />
</div>
<span className="font-medium">{label}</span>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600 sm:justify-end min-w-0">
<span className="truncate text-right sm:text-left">{value || "—"}</span>
{extra}
</div>
</div>
);
}
function VerifiedIcon({ verified }: { verified: boolean }) { function VerifiedIcon({ verified }: { verified: boolean }) {
return verified ? ( return verified ? (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100"> <div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100">
@ -121,20 +102,20 @@ function VerifiedIcon({ verified }: { verified: boolean }) {
} }
function ProgressRing({ percent }: { percent: number }) { function ProgressRing({ percent }: { percent: number }) {
const radius = 14; const radius = 18;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference; const offset = circumference - (percent / 100) * circumference;
return ( return (
<div className="relative inline-flex items-center justify-center"> <div className="relative inline-flex items-center justify-center">
<svg className="h-8 w-8 -rotate-90" viewBox="0 0 44 44"> <svg className="h-12 w-12 -rotate-90" viewBox="0 0 44 44">
<circle <circle
cx="22" cx="22"
cy="22" cy="22"
r={radius} r={radius}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="3" strokeWidth="2.5"
className="text-grayScale-200" className="text-grayScale-200"
/> />
<circle <circle
@ -143,14 +124,51 @@ function ProgressRing({ percent }: { percent: number }) {
r={radius} r={radius}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="3" strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={offset} strokeDashoffset={offset}
className="text-brand-500 transition-all duration-700" className="text-brand-500 transition-all duration-700"
/> />
</svg> </svg>
<span className="absolute text-[9px] font-bold text-brand-600">{percent}%</span> <span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
</div>
);
}
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 (
<div className="group flex items-start gap-3 rounded-xl px-3 py-3 transition-colors hover:bg-grayScale-50/80">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100/80 text-grayScale-400 transition-colors group-hover:bg-brand-50 group-hover:text-brand-500">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
{label}
</p>
{editing && editNode ? (
<div className="mt-1">{editNode}</div>
) : (
<div className="mt-0.5 flex items-center gap-2">
<p className="truncate text-sm font-medium text-grayScale-700">{value || "—"}</p>
{extra}
</div>
)}
</div>
</div> </div>
); );
} }
@ -159,6 +177,9 @@ export function ProfilePage() {
const [profile, setProfile] = useState<UserProfileData | null>(null); const [profile, setProfile] = useState<UserProfileData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [editForm, setEditForm] = useState<UpdateProfileRequest>({});
useEffect(() => { useEffect(() => {
const fetchProfile = async () => { const fetchProfile = async () => {
@ -175,11 +196,59 @@ export function ProfilePage() {
fetchProfile(); 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 <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
if (error || !profile) { if (error || !profile) {
return ( return (
<div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6"> <div className="w-full py-16">
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12"> <CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100"> <div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
@ -200,250 +269,483 @@ export function ProfilePage() {
} }
const fullName = `${profile.first_name} ${profile.last_name}`; 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; const completionPct = profile.profile_completion_percentage ?? 0;
return ( return (
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6"> <div className="w-full space-y-6">
{/* Page header (no tabs) */} {/* ─── Hero Card ─── */}
<div className="mb-5"> <div className="relative overflow-hidden rounded-2xl border border-grayScale-100 bg-white shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">My Info</p> {/* Tall dark gradient banner with content inside */}
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-grayScale-800">Profile</h1> <div className="relative flex min-h-[200px] flex-col justify-between bg-gradient-to-br from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-8 sm:px-8">
</div> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
{/* Main profile layout card */} <div className="relative z-10 space-y-2">
<div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm"> <h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
{/* Header strip */} Hello {profile.first_name}
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8"> </h2>
<div className="flex items-center justify-between"> <p className="max-w-xl text-sm leading-relaxed text-white/70">
<div> This is your profile page. You can see the progress you've made with your work and manage your projects or assigned tasks
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">Overview</p> </p>
<p className="mt-1 text-sm text-grayScale-500"> </div>
Personal, job and account details for this team member.
</p> <div className="relative z-10 mt-6">
</div> {!editing ? (
<Button
variant="outline"
size="sm"
className="gap-1.5 border-white/30 bg-white/10 text-xs font-medium text-white shadow-sm backdrop-blur-sm hover:bg-white/20 hover:text-white"
onClick={startEditing}
>
<Pencil className="h-3.5 w-3.5" />
Edit Profile
</Button>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-white/30 bg-white/10 text-xs text-white backdrop-blur-sm hover:bg-white/20 hover:text-white"
onClick={cancelEditing}
disabled={saving}
>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button
size="sm"
className="h-8 gap-1.5 bg-white text-xs text-[#1a1f4e] hover:bg-white/90"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{saving ? "Saving…" : "Save Changes"}
</Button>
</div>
)}
</div> </div>
</div> </div>
<div className="px-6 py-6 sm:px-8 sm:py-7"> {/* Identity info below banner */}
<div className="grid gap-8 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)]"> <div className="px-6 py-5 sm:px-8">
{/* Left column: About & details */} {editing ? (
<div className="space-y-6"> <div className="flex flex-wrap items-center gap-2">
{/* Identity */} <Input
<div className="flex flex-col gap-4 sm:flex-row"> className="h-9 w-40 text-sm font-semibold"
<Avatar className="h-16 w-16 sm:h-18 sm:w-18"> value={editForm.first_name ?? ""}
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} /> onChange={(e) => updateField("first_name", e.target.value)}
<AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600"> placeholder="First name"
{initials} />
</AvatarFallback> <Input
</Avatar> className="h-9 w-40 text-sm font-semibold"
<div className="min-w-0"> value={editForm.last_name ?? ""}
<div className="flex flex-wrap items-center gap-2"> onChange={(e) => updateField("last_name", e.target.value)}
<h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2> placeholder="Last name"
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500"> />
#{profile.id} <span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
</span> #{profile.id}
</div> </span>
<div className="mt-1 flex flex-wrap items-center gap-2"> </div>
<Badge ) : (
className={cn( <div className="flex flex-wrap items-center gap-2.5">
"px-2.5 py-0.5 text-xs font-semibold", <h2 className="text-xl font-bold tracking-tight text-grayScale-800 sm:text-2xl">
profile.role === "ADMIN" {fullName}
? "bg-brand-500/10 text-brand-600 border border-brand-500/20" </h2>
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200" {profile.nick_name && (
)} <span className="text-sm font-medium text-grayScale-400">
> @{profile.nick_name}
<Shield className="mr-1 h-3 w-3" /> </span>
{profile.role} )}
</Badge> <span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500"> #{profile.id}
<Calendar className="h-3 w-3" /> </span>
Joined {formatDate(profile.created_at)} </div>
</span> )}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "bg-mint-50 text-mint-600"
: "bg-destructive/10 text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
{profile.status}
</span>
<div className="inline-flex items-center gap-2 rounded-full border border-brand-100 bg-brand-50/60 px-2.5 py-0.5 text-xs font-semibold text-brand-600">
<ProgressRing percent={completionPct} />
<span>Profile complete</span>
</div>
</div>
</div>
</div>
{/* About / Contact */} {/* Badges row */}
<div> <div className="mt-3 flex flex-wrap items-center gap-2">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400"> <Badge
About className={cn(
</h3> "px-2.5 py-0.5 text-xs font-semibold",
<div className="space-y-1.5 rounded-xl border border-grayScale-100 bg-grayScale-50/60 px-3 py-3"> profile.role === "ADMIN"
<InfoRow icon={Phone} label="Phone" value={profile.phone_number} extra={<VerifiedIcon verified={profile.phone_verified} />} /> ? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
<InfoRow icon={Mail} label="Email" value={profile.email} extra={<VerifiedIcon verified={profile.email_verified} />} /> : "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
<InfoRow )}
>
<Shield className="mr-1 h-3 w-3" />
{profile.role}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "bg-mint-50 text-mint-600"
: "bg-destructive/10 text-destructive",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
)}
/>
{profile.status}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
<Calendar className="h-3 w-3" />
Joined {formatDate(profile.created_at)}
</span>
</div>
</div>
</div>
{/* ─── Detail Cards Grid ─── */}
<div className="grid gap-6 lg:grid-cols-3">
{/* ── Contact & Personal ── */}
<Card className="overflow-hidden border-grayScale-100 shadow-sm lg:col-span-2">
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
<CardContent className="p-0">
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
{/* Contact */}
<div className="p-5">
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Contact
</p>
<div className="space-y-1">
<DetailItem
icon={Mail}
label="Email"
value={profile.email}
extra={<VerifiedIcon verified={profile.email_verified} />}
/>
<DetailItem
icon={Phone}
label="Phone"
value={profile.phone_number}
extra={<VerifiedIcon verified={profile.phone_verified} />}
/>
<DetailItem
icon={MapPin} icon={MapPin}
label="Location" label="Location"
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"} value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
editing={editing}
editNode={
<div className="flex gap-2">
<Input
className="h-8 text-sm"
value={editForm.region ?? ""}
onChange={(e) => updateField("region", e.target.value)}
placeholder="Region"
/>
<Input
className="h-8 text-sm"
value={editForm.country ?? ""}
onChange={(e) => updateField("country", e.target.value)}
placeholder="Country"
/>
</div>
}
/>
<DetailItem
icon={Globe}
label="Preferred Language"
value={
{ en: "English", am: "Amharic", or: "Oromiffa", ti: "Tigrinya" }[
profile.preferred_language
] ?? profile.preferred_language ?? "—"
}
editing={editing}
editNode={
<Select
className="h-8 text-sm"
value={editForm.preferred_language ?? ""}
onChange={(e) => updateField("preferred_language", e.target.value)}
>
<option value="">Select</option>
<option value="en">English</option>
<option value="am">Amharic</option>
<option value="or">Oromiffa</option>
<option value="ti">Tigrinya</option>
</Select>
}
/> />
</div> </div>
</div> </div>
{/* Employee details */} {/* Personal */}
<div> <div className="p-5">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400"> <p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Employee details Personal
</h3> </p>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:text-sm text-grayScale-500"> <div className="space-y-1">
<div> <DetailItem
<dt className="text-grayScale-400">Date of birth</dt> icon={Calendar}
<dd className="mt-0.5 font-medium text-grayScale-700"> label="Date of Birth"
{formatDate(profile.birth_day)} value={formatDate(profile.birth_day)}
</dd> editing={editing}
</div> editNode={
<div> <Input
<dt className="text-grayScale-400">Age</dt> type="date"
<dd className="mt-0.5 font-medium text-grayScale-700"> className="h-8 text-sm"
{profile.age ? `${profile.age} years` : "—"} value={editForm.birth_day ?? ""}
</dd> onChange={(e) => updateField("birth_day", e.target.value)}
</div> />
<div> }
<dt className="text-grayScale-400">Gender</dt> />
<dd className="mt-0.5 font-medium text-grayScale-700"> <DetailItem
{profile.gender || "Not specified"} icon={User}
</dd> label="Gender"
</div> value={profile.gender || "Not specified"}
<div> editing={editing}
<dt className="text-grayScale-400">Age group</dt> editNode={
<dd className="mt-0.5 font-medium text-grayScale-700"> <Select
{profile.age_group || "—"} className="h-8 text-sm"
</dd> value={editForm.gender ?? ""}
</div> onChange={(e) => updateField("gender", e.target.value)}
<div>
<dt className="text-grayScale-400">Occupation</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.occupation || "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Preferred language</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.preferred_language || "—"}
</dd>
</div>
</dl>
</div>
</div>
{/* Right column: Activity & account summary */}
<div className="space-y-6">
{/* Activity */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Activity
</h3>
<Card className="shadow-none border-grayScale-100">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-700">
Last login
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.last_login)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-500">
<User className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-700">
Account created
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.created_at)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Account summary */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Account
</h3>
<Card className="shadow-none border-grayScale-100">
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Role</span>
<span className="font-medium text-grayScale-700">{profile.role}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Status</span>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "text-mint-600"
: "text-destructive"
)}
> >
<span <option value="">Select</option>
className={cn( <option value="Male">Male</option>
"h-1.5 w-1.5 rounded-full", <option value="Female">Female</option>
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive" <option value="Other">Other</option>
)} </Select>
/> }
{profile.status} />
</span> <DetailItem
</div> icon={User}
<div className="flex items-center justify-between text-sm"> label="Age Group"
<span className="text-grayScale-400">Email</span> value={profile.age_group?.replace("_", "") || "—"}
<span className="flex items-center gap-1 text-grayScale-700"> editing={editing}
<span className="truncate max-w-[140px] text-right text-xs sm:text-sm"> editNode={
{profile.email} <Select
</span> className="h-8 text-sm"
<VerifiedIcon verified={profile.email_verified} /> value={editForm.age_group ?? ""}
</span> onChange={(e) => updateField("age_group", e.target.value)}
</div> >
<div className="flex items-center justify-between text-sm"> <option value="">Select</option>
<span className="text-grayScale-400">Phone</span> <option value="18_24">1824</option>
<span className="flex items-center gap-1 text-grayScale-700"> <option value="25_34">2534</option>
<span className="truncate max-w-[120px] text-right text-xs sm:text-sm"> <option value="35_44">3544</option>
{profile.phone_number || "—"} <option value="45_54">4554</option>
</span> <option value="55_64">5564</option>
<VerifiedIcon verified={profile.phone_verified} /> <option value="65+">65+</option>
</span> </Select>
</div> }
</CardContent> />
</Card> <DetailItem
icon={Briefcase}
label="Occupation"
value={profile.occupation || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.occupation ?? ""}
onChange={(e) => updateField("occupation", e.target.value)}
placeholder="Occupation"
/>
}
/>
<DetailItem
icon={BookOpen}
label="Education"
value={profile.education_level || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.education_level ?? ""}
onChange={(e) => updateField("education_level", e.target.value)}
placeholder="Education level"
/>
}
/>
</div>
</div> </div>
</div> </div>
</div> </CardContent>
</Card>
{/* ── Right Sidebar ── */}
<div className="space-y-6">
{/* Profile Completion */}
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
<div className="h-1 bg-gradient-to-r from-brand-400 to-mint-400" />
<CardContent className="flex items-center gap-4 p-5">
<ProgressRing percent={completionPct} />
<div>
<p className="text-sm font-semibold text-grayScale-700">Profile Completion</p>
<p className="mt-0.5 text-xs text-grayScale-400">
{completionPct === 100 ? "All set!" : "Complete your profile for the best experience."}
</p>
</div>
</CardContent>
</Card>
{/* Activity */}
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
<div className="h-1 bg-gradient-to-r from-grayScale-300 to-grayScale-200" />
<CardContent className="space-y-4 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Activity
</p>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-500">
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Last Login</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.last_login)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-400">
<User className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Account Created</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.created_at)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Quick Account Info */}
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardContent className="space-y-3 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Account
</p>
<div className="space-y-2.5">
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Role</span>
<Badge
className={cn(
"text-[10px] font-semibold",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
)}
>
{profile.role}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Status</span>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE" ? "text-mint-600" : "text-destructive",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
)}
/>
{profile.status}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Email</span>
<span className="flex items-center gap-1">
<span className="max-w-[130px] truncate text-xs text-grayScale-600">
{profile.email}
</span>
<VerifiedIcon verified={profile.email_verified} />
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Phone</span>
<span className="flex items-center gap-1">
<span className="max-w-[110px] truncate text-xs text-grayScale-600">
{profile.phone_number || "—"}
</span>
<VerifiedIcon verified={profile.phone_verified} />
</span>
</div>
</div>
</CardContent>
</Card>
</div> </div>
</div> </div>
{/* ─── Learning & Goals Card ─── */}
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
<div className="h-1 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
<CardContent className="p-0">
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4 lg:divide-x lg:divide-y-0">
<div className="p-5">
<DetailItem
icon={Target}
label="Learning Goal"
value={profile.learning_goal || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.learning_goal ?? ""}
onChange={(e) => updateField("learning_goal", e.target.value)}
placeholder="Your learning goal"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={Languages}
label="Language Goal"
value={profile.language_goal || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.language_goal ?? ""}
onChange={(e) => updateField("language_goal", e.target.value)}
placeholder="Language goal"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={MessageCircle}
label="Language Challenge"
value={profile.language_challange || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.language_challange ?? ""}
onChange={(e) => updateField("language_challange", e.target.value)}
placeholder="Language challenge"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={Heart}
label="Favourite Topic"
value={profile.favoutite_topic || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.favoutite_topic ?? ""}
onChange={(e) => updateField("favoutite_topic", e.target.value)}
placeholder="Favourite topic"
/>
}
/>
</div>
</div>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@ -21,7 +21,7 @@ import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select"; import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator"; import { Separator } from "../components/ui/separator";
import { cn } from "../lib/utils"; 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 type { UserProfileData } from "../types/user.types";
import { toast } from "sonner"; import { toast } from "sonner";
@ -127,9 +127,15 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
// placeholder — wire up to API when endpoint is ready await updateProfile({
await new Promise((r) => setTimeout(r, 600)); first_name: firstName,
last_name: lastName,
nick_name: nickName,
preferred_language: language,
});
toast.success("Profile settings saved"); toast.success("Profile settings saved");
} catch {
toast.error("Failed to save profile settings.");
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" 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 practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -16,8 +16,8 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table" } from "../../components/ui/table"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api" import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse, getRatings } from "../../api/courses.api"
import type { Course, CourseCategory } from "../../types/course.types" import type { Course, CourseCategory, Rating } from "../../types/course.types"
export function CoursesPage() { export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
@ -43,6 +43,10 @@ export function CoursesPage() {
const [editThumbnail, setEditThumbnail] = useState("") const [editThumbnail, setEditThumbnail] = useState("")
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null) const [updateError, setUpdateError] = useState<string | null>(null)
const [showRatingsModal, setShowRatingsModal] = useState(false)
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
const fetchCourses = async () => { const fetchCourses = async () => {
if (!categoryId) return if (!categoryId) return
@ -212,6 +216,20 @@ export function CoursesPage() {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`) 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) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
@ -332,6 +350,17 @@ export function CoursesPage() {
</TableCell> </TableCell>
<TableCell className="py-3.5 text-right"> <TableCell className="py-3.5 text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-amber-400 hover:bg-amber-50 hover:text-amber-500"
onClick={(e) => {
e.stopPropagation()
handleViewRatings(course.id)
}}
>
<Star className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -542,6 +571,120 @@ export function CoursesPage() {
</div> </div>
)} )}
{/* Ratings Modal */}
{showRatingsModal && (
<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 animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
<h2 className="text-lg font-bold text-grayScale-700">Course Ratings</h2>
<button
onClick={() => {
setShowRatingsModal(false)
setRatingsCourseId(null)
setCourseRatings([])
}}
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="max-h-[70vh] overflow-y-auto px-6 py-6">
{courseRatingsLoading ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div>
) : courseRatings.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<div className="rounded-full bg-amber-50 p-4">
<Star className="h-8 w-8 text-amber-400" />
</div>
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
<p className="mt-1 text-sm text-grayScale-400">
Ratings will appear here once learners start reviewing this course.
</p>
</div>
) : (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex flex-wrap items-center gap-6 rounded-xl border border-grayScale-200 px-5 py-4">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-amber-400" fill="currentColor" />
<span className="text-lg font-bold text-grayScale-800">
{(courseRatings.reduce((sum, r) => sum + r.stars, 0) / courseRatings.length).toFixed(1)}
</span>
<span className="text-sm text-grayScale-400">/ 5</span>
</div>
<div className="h-5 w-px bg-grayScale-200" />
<span className="text-sm text-grayScale-500">
{courseRatings.length} review{courseRatings.length !== 1 ? "s" : ""}
</span>
</div>
{/* Rating cards */}
<div className="space-y-3">
{courseRatings.map((rating) => (
<div
key={rating.id}
className="rounded-xl border border-grayScale-200 bg-white p-5 space-y-3"
>
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-sm font-bold text-brand-600">
U{rating.user_id}
</div>
<div>
<p className="text-sm font-semibold text-grayScale-700">User #{rating.user_id}</p>
<p className="text-[11px] text-grayScale-400">
{new Date(rating.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{rating.updated_at !== rating.created_at && (
<span className="ml-1.5 text-grayScale-300">· edited</span>
)}
</p>
</div>
</div>
{/* Stars */}
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`h-4 w-4 ${
s <= rating.stars
? "text-amber-400"
: "text-grayScale-200"
}`}
fill={s <= rating.stars ? "currentColor" : "none"}
/>
))}
</div>
</div>
{/* Review text */}
{rating.review && (
<div className="flex gap-2">
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-300" />
<p className="text-sm leading-relaxed text-grayScale-600">
{rating.review}
</p>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Delete Course Modal */} {/* Delete Course Modal */}
{showDeleteModal && courseToDelete && ( {showDeleteModal && courseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" 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 spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg" import alertSrc from "../../assets/Alert.svg"
@ -15,11 +15,14 @@ import {
deleteQuestionSet, deleteQuestionSet,
createVimeoVideo, createVimeoVideo,
updateSubCourseVideo, updateSubCourseVideo,
deleteSubCourseVideo deleteSubCourseVideo,
getRatings,
getVimeoSample,
} from "../../api/courses.api" } 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" type StatusFilter = "all" | "published" | "draft" | "archived"
export function SubCourseContentPage() { export function SubCourseContentPage() {
@ -62,12 +65,27 @@ export function SubCourseContentPage() {
const [deletingVideo, setDeletingVideo] = useState(false) const [deletingVideo, setDeletingVideo] = useState(false)
const [openVideoMenuId, setOpenVideoMenuId] = useState<number | null>(null) const [openVideoMenuId, setOpenVideoMenuId] = useState<number | null>(null)
// Ratings state
const [ratings, setRatings] = useState<Rating[]>([])
const [ratingsLoading, setRatingsLoading] = useState(false)
const [ratingsPage, setRatingsPage] = useState(0)
const [ratingsPageSize] = useState(10)
const [videoTitle, setVideoTitle] = useState("") const [videoTitle, setVideoTitle] = useState("")
const [videoDescription, setVideoDescription] = useState("") const [videoDescription, setVideoDescription] = useState("")
const [videoUrl, setVideoUrl] = useState("") const [videoUrl, setVideoUrl] = useState("")
const [videoFileSize, setVideoFileSize] = useState<number>(0) const [videoFileSize, setVideoFileSize] = useState<number>(0)
const [videoDuration, setVideoDuration] = useState<number>(0) const [videoDuration, setVideoDuration] = useState<number>(0)
// Vimeo preview state
const [showPreviewModal, setShowPreviewModal] = useState(false)
const [previewIframe, setPreviewIframe] = useState("")
const [previewVideo, setPreviewVideo] = useState<VimeoSampleVideo | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [sampleVideoId, setSampleVideoId] = useState("")
const [modalPreviewIframe, setModalPreviewIframe] = useState("")
const [modalPreviewLoading, setModalPreviewLoading] = useState(false)
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!subCourseId || !courseId) return 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(() => { useEffect(() => {
if (activeTab === "practice") { if (activeTab === "practice") {
fetchPractices() fetchPractices()
} else { } else if (activeTab === "video") {
fetchVideos() fetchVideos()
} else if (activeTab === "ratings") {
fetchRatings(ratingsPage * ratingsPageSize)
} }
}, [activeTab, subCourseId]) }, [activeTab, subCourseId])
useEffect(() => {
if (activeTab === "ratings") {
fetchRatings(ratingsPage * ratingsPageSize)
}
}, [ratingsPage])
const handleAddPractice = () => { const handleAddPractice = () => {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`) 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) => { const filteredPractices = practices.filter((practice) => {
if (statusFilter === "all") return true if (statusFilter === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED" if (statusFilter === "published") return practice.status === "PUBLISHED"
@ -374,6 +459,19 @@ export function SubCourseContentPage() {
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" /> <span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)} )}
</button> </button>
<button
onClick={() => setActiveTab("ratings")}
className={`relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all ${
activeTab === "ratings"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700"
}`}
>
Ratings
{activeTab === "ratings" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
</div> </div>
</div> </div>
@ -569,15 +667,25 @@ export function SubCourseContentPage() {
{/* Title */} {/* Title */}
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3> <h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3>
{/* Edit button */} {/* Edit / Preview buttons */}
<Button <div className="flex gap-2">
variant="outline" <Button
className="w-full border-grayScale-200 text-grayScale-700 transition-colors hover:border-grayScale-300 hover:bg-grayScale-50" variant="outline"
onClick={() => handleEditVideoClick(video)} className="flex-1 border-grayScale-200 text-grayScale-700 transition-colors hover:border-grayScale-300 hover:bg-grayScale-50"
> onClick={() => handleEditVideoClick(video)}
<Edit className="mr-2 h-4 w-4" /> >
Edit <Edit className="mr-1.5 h-4 w-4" />
</Button> Edit
</Button>
<Button
variant="outline"
className="flex-1 border-brand-200 text-brand-600 transition-colors hover:border-brand-300 hover:bg-brand-50"
onClick={() => handlePreviewVideo(video)}
>
<Play className="mr-1.5 h-4 w-4" />
Preview
</Button>
</div>
{/* Publish button */} {/* Publish button */}
<Button <Button
@ -598,6 +706,135 @@ export function SubCourseContentPage() {
</> </>
)} )}
{/* Ratings Tab */}
{activeTab === "ratings" && (
<>
{ratingsLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div>
) : ratings.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16">
<div className="rounded-full bg-amber-50 p-4">
<Star className="h-8 w-8 text-amber-400" />
</div>
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
<p className="mt-1 text-sm text-grayScale-400">
Ratings will appear here once learners start reviewing this sub-course.
</p>
</div>
) : (
<div className="space-y-4">
{/* Summary bar */}
<Card className="overflow-hidden border-grayScale-200">
<div className="flex flex-wrap items-center gap-6 px-5 py-4">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-amber-400" fill="currentColor" />
<span className="text-lg font-bold text-grayScale-800">
{(ratings.reduce((sum, r) => sum + r.stars, 0) / ratings.length).toFixed(1)}
</span>
<span className="text-sm text-grayScale-400">
/ 5
</span>
</div>
<div className="h-5 w-px bg-grayScale-200" />
<span className="text-sm text-grayScale-500">
{ratings.length} review{ratings.length !== 1 ? "s" : ""} on this page
</span>
</div>
</Card>
{/* Rating cards */}
<div className="space-y-3">
{ratings.map((rating) => (
<Card
key={rating.id}
className="overflow-hidden border-grayScale-200 bg-white"
>
<div className="p-5 space-y-3">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-sm font-bold text-brand-600">
U{rating.user_id}
</div>
<div>
<p className="text-sm font-semibold text-grayScale-700">User #{rating.user_id}</p>
<p className="text-[11px] text-grayScale-400">
{new Date(rating.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{rating.updated_at !== rating.created_at && (
<span className="ml-1.5 text-grayScale-300">· edited</span>
)}
</p>
</div>
</div>
{/* Stars */}
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`h-4 w-4 ${
s <= rating.stars
? "text-amber-400"
: "text-grayScale-200"
}`}
fill={s <= rating.stars ? "currentColor" : "none"}
/>
))}
</div>
</div>
{/* Review text */}
{rating.review && (
<div className="flex gap-2">
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-300" />
<p className="text-sm leading-relaxed text-grayScale-600">
{rating.review}
</p>
</div>
)}
</div>
</Card>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
<p className="text-xs text-grayScale-400">
Page {ratingsPage + 1}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={ratingsPage <= 0}
onClick={() => setRatingsPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={ratings.length < ratingsPageSize}
onClick={() => setRatingsPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</>
)}
{/* Delete Modal */} {/* Delete Modal */}
{showDeleteModal && practiceToDelete && ( {showDeleteModal && practiceToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
@ -690,17 +927,48 @@ export function SubCourseContentPage() {
{/* Add Video Modal */} {/* Add Video Modal */}
{showAddVideoModal && ( {showAddVideoModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <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-md rounded-2xl bg-white shadow-2xl"> <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 className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2> <h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
<button <button
onClick={() => setShowAddVideoModal(false)} onClick={() => { setShowAddVideoModal(false); setSampleVideoId(""); setModalPreviewIframe("") }}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600" 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" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-5 px-6 py-6"> <div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-6">
{/* Sample Vimeo picker */}
<div className="rounded-xl border border-brand-100 bg-brand-50/40 p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-brand-600">
Try a sample Vimeo video
</p>
<div className="flex items-center gap-2">
<Select
className="flex-1 text-sm"
value={sampleVideoId}
onChange={(e) => {
setSampleVideoId(e.target.value)
handleModalPreview(e.target.value)
}}
>
<option value="">Select a sample video</option>
<option value="76979871">Big Buck Bunny</option>
<option value="1084537">Big Buck Bunny (alt)</option>
<option value="253989945">Vimeo Staff Pick</option>
<option value="305727901">Big Buck Bunny (4K)</option>
<option value="148751763">GoPro Footage</option>
</Select>
{modalPreviewLoading && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-brand-500" />}
</div>
{modalPreviewIframe && (
<div
className="aspect-video w-full overflow-hidden rounded-lg"
dangerouslySetInnerHTML={{ __html: modalPreviewIframe }}
/>
)}
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label> <label className="text-sm font-medium text-grayScale-700">Title</label>
<Input <Input
@ -855,6 +1123,48 @@ export function SubCourseContentPage() {
</div> </div>
</div> </div>
)} )}
{/* Video Preview Modal */}
{showPreviewModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="mx-4 w-full max-w-2xl 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-900">
{previewVideo?.name ?? "Video Preview"}
</h2>
{previewVideo && (
<p className="mt-0.5 text-xs text-grayScale-400">
{Math.floor(previewVideo.duration / 60)}:{(previewVideo.duration % 60).toString().padStart(2, "0")} {previewVideo.width}×{previewVideo.height}
</p>
)}
</div>
<button
onClick={() => setShowPreviewModal(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="p-6">
{previewLoading ? (
<div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
</div>
) : previewIframe ? (
<div
className="aspect-video w-full overflow-hidden rounded-xl"
dangerouslySetInnerHTML={{ __html: previewIframe }}
/>
) : (
<div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50">
<p className="text-sm text-grayScale-400">Failed to load preview.</p>
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -2,16 +2,17 @@ import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import {
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight, Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
Loader2, AlertCircle, Eye, X, Loader2, AlertCircle, Eye, X, Pencil, Check,
} from "lucide-react" } from "lucide-react"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "../../components/ui/dialog" } 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 type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
@ -34,6 +35,20 @@ export function RolesListPage() {
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = 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<Record<string, RolePermission[]>>({})
const [permLoading, setPermLoading] = useState(false)
const [selectedPermissionIds, setSelectedPermissionIds] = useState<Set<number>>(new Set())
const [permSearch, setPermSearch] = useState("")
const [savingPermissions, setSavingPermissions] = useState(false)
// Debounce search query // Debounce search query
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { 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 // Group permissions by group_name
const permissionGroups = useMemo(() => { const permissionGroups = useMemo(() => {
if (!selectedRole?.permissions) return [] if (!selectedRole?.permissions) return []
@ -93,6 +207,24 @@ export function RolesListPage() {
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)) return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [selectedRole]) }, [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)) const totalPages = Math.max(1, Math.ceil(total / pageSize))
return ( return (
@ -175,7 +307,7 @@ export function RolesListPage() {
className={cn( className={cn(
"h-1.5", "h-1.5",
role.is_system 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", : "bg-gradient-to-r from-brand-500 to-brand-600",
)} )}
/> />
@ -186,7 +318,7 @@ export function RolesListPage() {
className={cn( className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg", "flex h-9 w-9 items-center justify-center rounded-lg",
role.is_system role.is_system
? "bg-amber-50 text-amber-600" ? "bg-brand-100 text-brand-600"
: "bg-brand-50 text-brand-600", : "bg-brand-50 text-brand-600",
)} )}
> >
@ -265,20 +397,89 @@ export function RolesListPage() {
)} )}
{/* Role detail dialog */} {/* Role detail dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}> <Dialog open={detailOpen} onOpenChange={(open) => {
setDetailOpen(open)
if (!open) {
setEditingPermissions(false)
setEditingRole(false)
setPermSearch("")
}
}}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> {!editingRole ? (
{selectedRole?.is_system ? ( <>
<ShieldCheck className="h-5 w-5 text-amber-500" /> <DialogTitle className="flex items-center gap-2">
) : ( {selectedRole?.is_system ? (
<Shield className="h-5 w-5 text-brand-500" /> <ShieldCheck className="h-5 w-5 text-brand-500" />
)} ) : (
{selectedRole?.name ?? "Role Details"} <Shield className="h-5 w-5 text-brand-500" />
</DialogTitle> )}
<DialogDescription> {selectedRole?.name ?? "Role Details"}
{selectedRole?.description} {selectedRole && (
</DialogDescription> <Button
variant="ghost"
size="icon"
className="ml-auto h-7 w-7"
onClick={handleEditRole}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
</DialogTitle>
<DialogDescription>
{selectedRole?.description}
</DialogDescription>
</>
) : (
<>
<DialogTitle>Edit Role</DialogTitle>
<DialogDescription>Update the role name and description.</DialogDescription>
<div className="mt-3 space-y-3">
<div>
<label className="mb-1.5 block text-xs font-medium text-grayScale-500">
Role Name
</label>
<Input
value={editName}
onChange={(e) => setEditName(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={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Describe what this role can do…"
rows={2}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={handleCancelEditRole}
disabled={savingRole}
>
Cancel
</Button>
<Button
size="sm"
className="h-8 gap-1.5 bg-brand-500 text-xs hover:bg-brand-600"
onClick={handleSaveRole}
disabled={savingRole || !editName.trim()}
>
{savingRole && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{savingRole ? "Saving…" : "Save"}
</Button>
</div>
</div>
</>
)}
</DialogHeader> </DialogHeader>
{detailLoading && ( {detailLoading && (
@ -302,31 +503,184 @@ export function RolesListPage() {
</span> </span>
</div> </div>
{/* Permissions grouped */} {/* Permissions section */}
<div> <div>
<h4 className="mb-3 text-sm font-semibold text-grayScale-600">Permissions</h4> <div className="mb-3 flex items-center justify-between">
{permissionGroups.length === 0 ? ( <h4 className="text-sm font-semibold text-grayScale-600">Permissions</h4>
<p className="text-xs italic text-grayScale-400">No permissions assigned.</p> {!editingPermissions && (
) : ( <Button
<div className="space-y-4"> variant="outline"
{permissionGroups.map(([groupName, perms]) => ( size="sm"
<div key={groupName}> className="h-7 gap-1.5 text-xs"
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-400"> onClick={handleEditPermissions}
{groupName} >
</p> <Pencil className="h-3 w-3" />
<div className="flex flex-wrap gap-1.5"> Edit Permissions
{perms.map((p) => ( </Button>
<span )}
key={p.id} </div>
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" {/* VIEW mode */}
> {!editingPermissions && (
{p.name} <>
</span> {permissionGroups.length === 0 ? (
))} <p className="text-xs italic text-grayScale-400">No permissions assigned.</p>
</div> ) : (
<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>
))} )}
</>
)}
{/* EDIT mode */}
{editingPermissions && (
<div className="space-y-4">
{/* Search & actions bar */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<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>
<span className="shrink-0 text-xs text-grayScale-400">
{selectedPermissionIds.size} selected
</span>
</div>
{permLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
</div>
)}
{!permLoading && (
<div className="max-h-[400px] space-y-5 overflow-y-auto pr-1">
{editPermissionGroups.length === 0 ? (
<p className="py-6 text-center text-xs text-grayScale-400">
{permSearch ? "No permissions match your search." : "No permissions available."}
</p>
) : (
editPermissionGroups.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}>
<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>
<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>
)}
{/* Save / Cancel */}
<div className="flex items-center justify-end gap-2 border-t border-grayScale-100 pt-3">
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={handleCancelEdit}
disabled={savingPermissions}
>
Cancel
</Button>
<Button
size="sm"
className="h-8 gap-1.5 bg-brand-500 text-xs hover:bg-brand-600"
onClick={handleSavePermissions}
disabled={savingPermissions}
>
{savingPermissions && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{savingPermissions ? "Saving…" : "Save Permissions"}
</Button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Search, Search,
@ -7,6 +7,7 @@ import {
SlidersHorizontal, SlidersHorizontal,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
X,
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
@ -20,7 +21,7 @@ import {
} from "../../components/ui/table"; } from "../../components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { getTeamMembers } from "../../api/team.api"; import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types"; import type { TeamMember } from "../../types/team.types";
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@ -88,6 +89,10 @@ export function TeamManagementPage() {
const [roleFilter, setRoleFilter] = useState(""); const [roleFilter, setRoleFilter] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({}); const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
const [countdown, setCountdown] = useState(5);
const [updating, setUpdating] = useState(false);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { useEffect(() => {
const fetchMembers = async () => { const fetchMembers = async () => {
@ -133,7 +138,45 @@ export function TeamManagementPage() {
}; };
const handleToggle = (id: number) => { const handleToggle = (id: number) => {
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] })); const member = members.find((m) => m.id === id);
if (!member) return;
const currentlyActive = toggledStatuses[id] ?? false;
const newStatus = currentlyActive ? "inactive" : "active";
setConfirmDialog({ id, name: `${member.first_name} ${member.last_name}`, newStatus });
setCountdown(5);
if (countdownRef.current) clearInterval(countdownRef.current);
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownRef.current) clearInterval(countdownRef.current);
return 0;
}
return prev - 1;
});
}, 1000);
};
const handleConfirmStatusUpdate = async () => {
if (!confirmDialog) return;
const { id, newStatus } = confirmDialog;
const previousActive = toggledStatuses[id] ?? false;
setUpdating(true);
setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" }));
try {
await updateTeamMemberStatus(id, newStatus);
} catch (error) {
console.error("Failed to update member status:", error);
setToggledStatuses((prev) => ({ ...prev, [id]: previousActive }));
} finally {
setUpdating(false);
handleCancelConfirm();
}
};
const handleCancelConfirm = () => {
if (countdownRef.current) clearInterval(countdownRef.current);
setConfirmDialog(null);
setCountdown(5);
}; };
return ( return (
@ -372,6 +415,46 @@ export function TeamManagementPage() {
</div> </div>
</div> </div>
</div> </div>
{/* Status Update Confirmation Modal */}
{confirmDialog && (
<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-sm rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Confirm Status Change</h2>
<button
onClick={handleCancelConfirm}
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-6">
<p className="text-sm leading-relaxed text-grayScale-600">
Are you sure you want to change the status of{" "}
<span className="font-semibold">{confirmDialog.name}</span> to{" "}
<span className="font-semibold capitalize">{confirmDialog.newStatus}</span>?
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleCancelConfirm} disabled={updating}>
Cancel
</Button>
<Button
className="bg-brand-600 hover:bg-brand-500 text-white"
onClick={handleConfirmStatusUpdate}
disabled={countdown > 0 || updating}
>
{updating
? "Updating..."
: countdown > 0
? `Confirm (${countdown}s)`
: "Confirm"}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -2,47 +2,19 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { import {
ArrowLeft, ArrowLeft,
Briefcase,
Building2,
Calendar,
CheckCircle2,
Clock,
Globe,
KeyRound, KeyRound,
Mail, MessageCircle,
Phone,
Shield, Shield,
User, User,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { Separator } from "../../components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { getTeamMemberById } from "../../api/team.api"; import { getTeamMemberById } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types"; import type { TeamMember } from "../../types/team.types";
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatRoleLabel(role: string): string { function formatRoleLabel(role: string): string {
return role return role
.split("_") .split("_")
@ -80,16 +52,28 @@ function getRoleBadgeClasses(role: string): string {
} }
} }
function ReadOnlyField({ label, value }: { label: string; value: string }) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-400">
{label}
</label>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-2.5 text-sm text-grayScale-600">
{value || "—"}
</div>
</div>
);
}
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="h-5 w-32 animate-pulse rounded bg-grayScale-100" /> <div className="h-5 w-32 animate-pulse rounded bg-grayScale-100" />
<div className="animate-pulse"> <div className="animate-pulse">
<div className="rounded-2xl bg-grayScale-100 h-64" /> <div className="rounded-2xl bg-grayScale-100 h-[200px]" />
<div className="mt-6 grid gap-6 lg:grid-cols-3"> <div className="mt-6 grid gap-6 lg:grid-cols-3">
<div className="rounded-2xl bg-grayScale-100 h-52" /> <div className="lg:col-span-2 rounded-2xl bg-grayScale-100 h-96" />
<div className="rounded-2xl bg-grayScale-100 h-52" /> <div className="rounded-2xl bg-grayScale-100 h-96" />
<div className="rounded-2xl bg-grayScale-100 h-52" />
</div> </div>
</div> </div>
</div> </div>
@ -157,176 +141,177 @@ export function TeamMemberDetailPage() {
Back to Team Back to Team
</Link> </Link>
<Card className="overflow-hidden"> {/* Hero Banner */}
<div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" /> <div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-12 sm:px-10 sm:py-14">
<CardContent className="-mt-12 px-4 sm:px-8 pb-4 sm:pb-8 pt-0"> <div className="relative z-10 max-w-2xl">
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end"> <h1 className="text-3xl font-bold text-white sm:text-4xl">
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft"> Hello {member.first_name}
<AvatarImage src={undefined} alt={fullName} /> </h1>
<AvatarFallback className="bg-brand-100 text-brand-600 text-2xl font-bold"> <p className="mt-3 text-sm leading-relaxed text-white/70">
{initials} This is the profile page. You can see the progress made with their
</AvatarFallback> work and manage their projects or assigned tasks
</Avatar> </p>
<Button className="mt-5 rounded-full bg-brand-600 px-6 hover:bg-brand-500">
Edit profile
</Button>
</div>
{/* Decorative circles */}
<div className="pointer-events-none absolute -right-10 -top-10 h-52 w-52 rounded-full bg-white/5" />
<div className="pointer-events-none absolute -bottom-16 right-20 h-40 w-40 rounded-full bg-white/5" />
</div>
<div className="flex-1 pb-1"> {/* Two-column layout */}
<h1 className="text-2xl font-bold text-grayScale-600">{fullName}</h1> <div className="grid gap-6 lg:grid-cols-3">
<p className="mt-0.5 text-sm text-grayScale-400">{member.job_title} · {member.department}</p> {/* Left: My Account Card */}
<div className="mt-2 flex flex-wrap items-center gap-2"> <Card className="lg:col-span-2">
<span <CardHeader className="flex-row items-center justify-between space-y-0">
className={cn( <CardTitle className="text-lg">My account</CardTitle>
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold", <Button
getRoleBadgeClasses(member.team_role) size="sm"
)} className="rounded-full bg-brand-600 px-5 hover:bg-brand-500"
> >
<Shield className="h-3 w-3" /> Settings
{formatRoleLabel(member.team_role)} </Button>
</span> </CardHeader>
<span <CardContent className="space-y-6">
className={cn( {/* User Information */}
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium", <div>
member.status === "active" <h4 className="mb-4 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
? "bg-mint-100 text-mint-500" User Information
: "bg-destructive/10 text-destructive" </h4>
)} <div className="grid gap-4 sm:grid-cols-2">
> <ReadOnlyField label="Username" value={member.email} />
<ReadOnlyField label="Email address" value={member.email} />
<ReadOnlyField label="First name" value={member.first_name} />
<ReadOnlyField label="Last name" value={member.last_name} />
<ReadOnlyField label="Job Title" value={member.job_title} />
<ReadOnlyField label="Department" value={member.department} />
</div>
</div>
{/* Contact Information */}
<div>
<h4 className="mb-4 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
Contact Information
</h4>
<div className="grid gap-4 sm:grid-cols-2">
<ReadOnlyField label="Phone" value={member.phone_number} />
<ReadOnlyField
label="Employment Type"
value={formatEmploymentType(member.employment_type)}
/>
</div>
</div>
{/* About Me */}
<div>
<h4 className="mb-4 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
About Me
</h4>
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-3 text-sm leading-relaxed text-grayScale-600">
{member.bio || "—"}
</div>
</div>
{/* Permissions */}
{member.permissions.length > 0 && (
<div>
<h4 className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
<KeyRound className="h-3.5 w-3.5" />
Permissions
</h4>
<div className="flex flex-wrap gap-2">
{member.permissions.map((perm) => (
<Badge
key={perm}
className="bg-grayScale-100 text-grayScale-600 border border-grayScale-200 font-mono text-xs"
>
{perm}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Right: Profile Card */}
<Card>
<CardContent className="flex flex-col items-center p-6">
{/* Avatar with gradient ring */}
<div className="relative mt-2">
<div className="rounded-full bg-gradient-to-br from-brand-400 via-brand-600 to-mint-500 p-1">
<Avatar className="h-28 w-28 ring-4 ring-white">
<AvatarImage src={undefined} alt={fullName} />
<AvatarFallback className="bg-brand-100 text-brand-600 text-3xl font-bold">
{initials}
</AvatarFallback>
</Avatar>
</div>
</div>
<h3 className="mt-4 text-lg font-bold text-grayScale-600">
{fullName}
</h3>
<p className="text-sm text-grayScale-400">{member.job_title}</p>
{/* Action buttons */}
<div className="mt-5 flex w-full gap-3">
<Button className="flex-1 rounded-full bg-mint-500 text-white hover:bg-mint-300">
Connect
</Button>
<Button className="flex-1 rounded-full bg-grayScale-600 text-white hover:bg-grayScale-500">
<MessageCircle className="h-4 w-4" />
Message
</Button>
</div>
{/* Stats row */}
<div className="mt-6 grid w-full grid-cols-3 divide-x divide-grayScale-200 text-center">
<div className="px-2">
<p className="text-xs font-medium text-grayScale-400">Role</p>
<p className="mt-1">
<span <span
className={cn( className={cn(
"h-1.5 w-1.5 rounded-full", "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
member.status === "active" ? "bg-mint-500" : "bg-destructive" getRoleBadgeClasses(member.team_role)
)} )}
/> >
{member.status === "active" ? "Active" : "Inactive"} <Shield className="h-3 w-3" />
</span> {formatRoleLabel(member.team_role)}
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-100 px-2.5 py-0.5 text-xs font-medium text-grayScale-500"> </span>
</p>
</div>
<div className="px-2">
<p className="text-xs font-medium text-grayScale-400">Status</p>
<p className="mt-1">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
member.status === "active"
? "bg-mint-100 text-mint-500"
: "bg-destructive/10 text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
member.status === "active" ? "bg-mint-500" : "bg-destructive"
)}
/>
{member.status === "active" ? "Active" : "Inactive"}
</span>
</p>
</div>
<div className="px-2">
<p className="text-xs font-medium text-grayScale-400">Type</p>
<p className="mt-1 text-xs font-medium text-grayScale-600">
{formatEmploymentType(member.employment_type)} {formatEmploymentType(member.employment_type)}
</span> </p>
</div> </div>
</div> </div>
</div>
{member.bio && (
<div className="mt-5 rounded-xl bg-grayScale-100 p-4 text-sm leading-relaxed text-grayScale-600">
{member.bio}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-100/50">
<User className="h-4 w-4 text-brand-600" />
</div>
<CardTitle>Work Details</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0">
<DetailRow icon={Briefcase} label="Job Title" value={member.job_title} />
<Separator />
<DetailRow icon={Building2} label="Department" value={member.department} />
<Separator />
<DetailRow icon={Globe} label="Employment" value={formatEmploymentType(member.employment_type)} />
<Separator />
<DetailRow icon={Calendar} label="Hire Date" value={formatDate(member.hire_date)} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-mint-100/60">
<Mail className="h-4 w-4 text-mint-500" />
</div>
<CardTitle>Contact</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0">
<DetailRow
icon={Mail}
label="Email"
value={member.email}
extra={
member.email_verified ? (
<CheckCircle2 className="h-4 w-4 text-mint-500" />
) : (
<XCircle className="h-4 w-4 text-grayScale-300" />
)
}
/>
<Separator />
<DetailRow icon={Phone} label="Phone" value={member.phone_number} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold-100/60">
<Shield className="h-4 w-4 text-gold-600" />
</div>
<CardTitle>Account</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0">
<DetailRow icon={Shield} label="Role" value={formatRoleLabel(member.team_role)} />
<Separator />
<DetailRow icon={Clock} label="Last Login" value={formatDateTime(member.last_login)} />
<Separator />
<DetailRow icon={Calendar} label="Member Since" value={formatDate(member.created_at)} />
</CardContent>
</Card>
</div>
{member.permissions.length > 0 && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-100/50">
<KeyRound className="h-4 w-4 text-brand-600" />
</div>
<CardTitle>Permissions</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{member.permissions.map((perm) => (
<Badge
key={perm}
className="bg-grayScale-100 text-grayScale-600 border border-grayScale-200 font-mono text-xs"
>
{perm}
</Badge>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
)}
</div>
);
}
function DetailRow({
icon: Icon,
label,
value,
extra,
}: {
icon: typeof User;
label: string;
value: string;
extra?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3 text-sm text-grayScale-400">
<Icon className="h-4 w-4" />
<span>{label}</span>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<span>{value || "—"}</span>
{extra}
</div> </div>
</div> </div>
); );

View File

@ -1,3 +1,4 @@
import { useEffect, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { import {
Users, Users,
@ -7,10 +8,32 @@ import {
ArrowRight, ArrowRight,
List, List,
UsersRound, UsersRound,
Loader2,
} from "lucide-react" } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { getUserSummary } from "../../api/users.api"
import type { UserSummary } from "../../types/user.types"
export function UserManagementDashboard() { export function UserManagementDashboard() {
const [stats, setStats] = useState<UserSummary | null>(null)
const [statsLoading, setStatsLoading] = useState(true)
useEffect(() => {
const fetchStats = async () => {
try {
const res = await getUserSummary()
setStats(res.data.data)
} catch {
// silently fail — cards will show "—"
} finally {
setStatsLoading(false)
}
}
fetchStats()
}, [])
const formatNum = (n: number) => n.toLocaleString()
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Page Header */} {/* Page Header */}
@ -30,7 +53,9 @@ export function UserManagementDashboard() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-white/80">Total Users</p> <p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white">1,248</p> <p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.total_users) : "—"}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -42,7 +67,9 @@ export function UserManagementDashboard() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-white/80">Active Users</p> <p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white">1,180</p> <p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.active_users) : "—"}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -54,7 +81,9 @@ export function UserManagementDashboard() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-white/80">New This Month</p> <p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white">64</p> <p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.joined_this_month) : "—"}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -501,3 +501,58 @@ export interface ReorderItem {
sub_course_id: number sub_course_id: number
display_order: number display_order: number
} }
// Ratings
export interface Rating {
id: number
user_id: number
target_type: string
target_id: number
stars: number
review: string
created_at: string
updated_at: string
}
export interface GetRatingsParams {
target_type: string
target_id: number
limit?: number
offset?: number
}
export interface GetRatingsResponse {
message: string
data: Rating[]
success: boolean
status_code: number
metadata: unknown
}
// Vimeo Sample Video
export interface VimeoSampleVideo {
vimeo_id: string
uri: string
name: string
description: string
duration: number
width: number
height: number
link: string
embed_url: string
embed_html: string
thumbnail_url: string
status: string
transcode_status: string
}
export interface GetVimeoSampleResponse {
message: string
data: {
video: VimeoSampleVideo
iframe: string
}
success: boolean
status_code: number
metadata: unknown
}

View File

@ -110,3 +110,35 @@ export interface UserProfileResponse {
data: UserProfileData data: UserProfileData
timestamp: string timestamp: string
} }
export interface UserSummary {
total_users: number
active_users: number
joined_this_month: number
}
export interface UserSummaryResponse {
message: string
data: UserSummary
success: boolean
status_code: number
}
export interface UpdateProfileRequest {
first_name?: string
last_name?: string
gender?: string
birth_day?: string
age_group?: string
education_level?: string
country?: string
region?: string
nick_name?: string
occupation?: string
learning_goal?: string
language_goal?: string
language_challange?: string
favoutite_topic?: string
profile_picture_url?: string
preferred_language?: string
}