minor fixes
This commit is contained in:
parent
3ecd35f960
commit
6a201a0108
|
|
@ -42,6 +42,9 @@ import type {
|
|||
AddSubCoursePrerequisiteRequest,
|
||||
GetLearningPathResponse,
|
||||
ReorderItem,
|
||||
GetRatingsResponse,
|
||||
GetRatingsParams,
|
||||
GetVimeoSampleResponse,
|
||||
} from "../types/course.types"
|
||||
|
||||
export const getCourseCategories = () =>
|
||||
|
|
@ -216,3 +219,13 @@ export const getLearningPath = (courseId: number) =>
|
|||
|
||||
export const reorderSubCourses = (courseId: number, items: ReorderItem[]) =>
|
||||
http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items })
|
||||
|
||||
// Ratings
|
||||
export const getRatings = (params: GetRatingsParams) =>
|
||||
http.get<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 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export const getRoleDetail = (roleId: number) =>
|
|||
export const createRole = (data: CreateRoleRequest) =>
|
||||
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) =>
|
||||
http.put(`/rbac/roles/${roleId}/permissions`, data)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,3 +14,6 @@ export const getTeamMemberById = (id: number) =>
|
|||
|
||||
export const createTeamMember = (data: CreateTeamMemberRequest) =>
|
||||
http.post("/team/register", data)
|
||||
|
||||
export const updateTeamMemberStatus = (id: number, status: string) =>
|
||||
http.patch(`/team/members/${id}/status`, { status })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import http from "./http";
|
||||
import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types";
|
||||
import { type UserProfileResponse, type GetUsersResponse, type UpdateProfileRequest, type UserSummaryResponse } from "../types/user.types";
|
||||
|
||||
export const getUsers = (
|
||||
page?: number,
|
||||
|
|
@ -37,3 +37,9 @@ export interface CreateUserRequest {
|
|||
|
||||
export const createUser = (payload: CreateUserRequest) =>
|
||||
http.post("/users", payload);
|
||||
|
||||
export const updateProfile = (data: UpdateProfileRequest) =>
|
||||
http.put<UserProfileResponse>("/user", data);
|
||||
|
||||
export const getUserSummary = () =>
|
||||
http.get<UserSummaryResponse>("/users/summary");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
// Coins,
|
||||
DollarSign,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
Star,
|
||||
TicketCheck,
|
||||
// TrendingUp,
|
||||
Users,
|
||||
|
|
@ -32,8 +34,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
|||
import { cn } from "../lib/utils"
|
||||
import { getTeamMemberById } from "../api/team.api"
|
||||
import { getDashboard } from "../api/analytics.api"
|
||||
import { getRatings } from "../api/courses.api"
|
||||
import { useEffect, useState } from "react"
|
||||
import type { DashboardData } from "../types/analytics.types"
|
||||
import type { Rating } from "../types/course.types"
|
||||
|
||||
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
||||
|
||||
|
|
@ -47,6 +51,8 @@ export function DashboardPage() {
|
|||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
||||
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
||||
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
|
|
@ -75,8 +81,20 @@ export function DashboardPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchAppRatings = async () => {
|
||||
try {
|
||||
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
||||
setAppRatings(res.data.data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setAppRatingsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUser()
|
||||
fetchDashboard()
|
||||
fetchAppRatings()
|
||||
}, [])
|
||||
|
||||
const registrationData =
|
||||
|
|
@ -409,6 +427,90 @@ export function DashboardPage() {
|
|||
</Card>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,23 +4,33 @@ import {
|
|||
CheckCircle2,
|
||||
Clock,
|
||||
Globe,
|
||||
// GraduationCap,
|
||||
Languages,
|
||||
Loader2,
|
||||
Mail,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Phone,
|
||||
Save,
|
||||
Shield,
|
||||
User,
|
||||
X,
|
||||
XCircle,
|
||||
Briefcase,
|
||||
// RefreshCw,
|
||||
BookOpen,
|
||||
Target,
|
||||
Languages,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/select";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
import { getMyProfile } from "../api/users.api";
|
||||
import type { UserProfileData } from "../types/user.types";
|
||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
||||
import type { UserProfileData, UpdateProfileRequest } from "../types/user.types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "—";
|
||||
|
|
@ -44,11 +54,10 @@ function formatDateTime(dateStr: string | null | undefined): string {
|
|||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<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">
|
||||
{/* Hero skeleton */}
|
||||
<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="-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" />
|
||||
|
|
@ -60,7 +69,6 @@ function LoadingSkeleton() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Info cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<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 }) {
|
||||
return verified ? (
|
||||
<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 }) {
|
||||
const radius = 14;
|
||||
const radius = 18;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percent / 100) * circumference;
|
||||
|
||||
return (
|
||||
<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
|
||||
cx="22"
|
||||
cy="22"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeWidth="2.5"
|
||||
className="text-grayScale-200"
|
||||
/>
|
||||
<circle
|
||||
|
|
@ -143,14 +124,51 @@ function ProgressRing({ percent }: { percent: number }) {
|
|||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
className="text-brand-500 transition-all duration-700"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -159,6 +177,9 @@ export function ProfilePage() {
|
|||
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editForm, setEditForm] = useState<UpdateProfileRequest>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
|
|
@ -175,11 +196,59 @@ export function ProfilePage() {
|
|||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const startEditing = () => {
|
||||
if (!profile) return;
|
||||
setEditForm({
|
||||
first_name: profile.first_name ?? "",
|
||||
last_name: profile.last_name ?? "",
|
||||
nick_name: profile.nick_name ?? "",
|
||||
gender: profile.gender ?? "",
|
||||
birth_day: profile.birth_day ?? "",
|
||||
age_group: profile.age_group ?? "",
|
||||
education_level: profile.education_level ?? "",
|
||||
country: profile.country ?? "",
|
||||
region: profile.region ?? "",
|
||||
occupation: profile.occupation ?? "",
|
||||
learning_goal: profile.learning_goal ?? "",
|
||||
language_goal: profile.language_goal ?? "",
|
||||
language_challange: profile.language_challange ?? "",
|
||||
favoutite_topic: profile.favoutite_topic ?? "",
|
||||
preferred_language: profile.preferred_language ?? "",
|
||||
});
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditing(false);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateProfile(editForm);
|
||||
const res = await getMyProfile();
|
||||
setProfile(res.data.data);
|
||||
setEditing(false);
|
||||
setEditForm({});
|
||||
toast.success("Profile updated successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to update profile", err);
|
||||
toast.error("Failed to update profile. Please try again.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof UpdateProfileRequest, value: string) => {
|
||||
setEditForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
|
||||
if (error || !profile) {
|
||||
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">
|
||||
<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">
|
||||
|
|
@ -200,220 +269,382 @@ export function ProfilePage() {
|
|||
}
|
||||
|
||||
const fullName = `${profile.first_name} ${profile.last_name}`;
|
||||
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
|
||||
const completionPct = profile.profile_completion_percentage ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">
|
||||
{/* Page header (no tabs) */}
|
||||
<div className="mb-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">My Info</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-grayScale-800">Profile</h1>
|
||||
</div>
|
||||
<div className="w-full space-y-6">
|
||||
{/* ─── Hero Card ─── */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-grayScale-100 bg-white shadow-sm">
|
||||
{/* Tall dark gradient banner with content inside */}
|
||||
<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 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="rounded-2xl border border-grayScale-100 bg-white shadow-sm">
|
||||
{/* Header strip */}
|
||||
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">Overview</p>
|
||||
<p className="mt-1 text-sm text-grayScale-500">
|
||||
Personal, job and account details for this team member.
|
||||
<div className="relative z-10 space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
|
||||
Hello {profile.first_name}
|
||||
</h2>
|
||||
<p className="max-w-xl text-sm leading-relaxed text-white/70">
|
||||
This is your profile page. You can see the progress you've made with your work and manage your projects or assigned tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-6">
|
||||
{!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 className="px-6 py-6 sm:px-8 sm:py-7">
|
||||
<div className="grid gap-8 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)]">
|
||||
{/* Left column: About & details */}
|
||||
<div className="space-y-6">
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Avatar className="h-16 w-16 sm:h-18 sm:w-18">
|
||||
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
|
||||
<AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
{/* Identity info below banner */}
|
||||
<div className="px-6 py-5 sm:px-8">
|
||||
{editing ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2>
|
||||
<Input
|
||||
className="h-9 w-40 text-sm font-semibold"
|
||||
value={editForm.first_name ?? ""}
|
||||
onChange={(e) => updateField("first_name", e.target.value)}
|
||||
placeholder="First name"
|
||||
/>
|
||||
<Input
|
||||
className="h-9 w-40 text-sm font-semibold"
|
||||
value={editForm.last_name ?? ""}
|
||||
onChange={(e) => updateField("last_name", e.target.value)}
|
||||
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>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h2 className="text-xl font-bold tracking-tight text-grayScale-800 sm:text-2xl">
|
||||
{fullName}
|
||||
</h2>
|
||||
{profile.nick_name && (
|
||||
<span className="text-sm font-medium text-grayScale-400">
|
||||
@{profile.nick_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
||||
#{profile.id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges row */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
className={cn(
|
||||
"px-2.5 py-0.5 text-xs 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"
|
||||
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
|
||||
)}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
{profile.role}
|
||||
</Badge>
|
||||
<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 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"
|
||||
: "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 === "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>
|
||||
<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>
|
||||
|
||||
{/* About / Contact */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
About
|
||||
</h3>
|
||||
<div className="space-y-1.5 rounded-xl border border-grayScale-100 bg-grayScale-50/60 px-3 py-3">
|
||||
<InfoRow icon={Phone} label="Phone" value={profile.phone_number} extra={<VerifiedIcon verified={profile.phone_verified} />} />
|
||||
<InfoRow icon={Mail} label="Email" value={profile.email} extra={<VerifiedIcon verified={profile.email_verified} />} />
|
||||
<InfoRow
|
||||
{/* ─── 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}
|
||||
label="Location"
|
||||
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>
|
||||
|
||||
{/* Employee details */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Employee details
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:text-sm text-grayScale-500">
|
||||
<div>
|
||||
<dt className="text-grayScale-400">Date of birth</dt>
|
||||
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||
{formatDate(profile.birth_day)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-grayScale-400">Age</dt>
|
||||
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||
{profile.age ? `${profile.age} years` : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-grayScale-400">Gender</dt>
|
||||
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||
{profile.gender || "Not specified"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-grayScale-400">Age group</dt>
|
||||
<dd className="mt-0.5 font-medium text-grayScale-700">
|
||||
{profile.age_group || "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<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)}
|
||||
{/* Personal */}
|
||||
<div className="p-5">
|
||||
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
||||
Personal
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<DetailItem
|
||||
icon={Calendar}
|
||||
label="Date of Birth"
|
||||
value={formatDate(profile.birth_day)}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-sm"
|
||||
value={editForm.birth_day ?? ""}
|
||||
onChange={(e) => updateField("birth_day", e.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={User}
|
||||
label="Gender"
|
||||
value={profile.gender || "Not specified"}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<Select
|
||||
className="h-8 text-sm"
|
||||
value={editForm.gender ?? ""}
|
||||
onChange={(e) => updateField("gender", e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
<option value="Other">Other</option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={User}
|
||||
label="Age Group"
|
||||
value={profile.age_group?.replace("_", "–") || "—"}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<Select
|
||||
className="h-8 text-sm"
|
||||
value={editForm.age_group ?? ""}
|
||||
onChange={(e) => updateField("age_group", e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="18_24">18–24</option>
|
||||
<option value="25_34">25–34</option>
|
||||
<option value="35_44">35–44</option>
|
||||
<option value="45_54">45–54</option>
|
||||
<option value="55_64">55–64</option>
|
||||
<option value="65+">65+</option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
<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 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 */}
|
||||
{/* ── 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>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
<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
|
||||
</h3>
|
||||
<Card className="shadow-none border-grayScale-100">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
<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>
|
||||
<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"
|
||||
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 === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
{profile.status}
|
||||
|
|
@ -421,8 +652,8 @@ export function ProfilePage() {
|
|||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-grayScale-400">Email</span>
|
||||
<span className="flex items-center gap-1 text-grayScale-700">
|
||||
<span className="truncate max-w-[140px] text-right text-xs sm:text-sm">
|
||||
<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} />
|
||||
|
|
@ -430,20 +661,91 @@ export function ProfilePage() {
|
|||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-grayScale-400">Phone</span>
|
||||
<span className="flex items-center gap-1 text-grayScale-700">
|
||||
<span className="truncate max-w-[120px] text-right text-xs sm:text-sm">
|
||||
<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>
|
||||
|
||||
{/* ─── 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Button } from "../components/ui/button";
|
|||
import { Select } from "../components/ui/select";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import { cn } from "../lib/utils";
|
||||
import { getMyProfile } from "../api/users.api";
|
||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
||||
import type { UserProfileData } from "../types/user.types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -127,9 +127,15 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
|||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// placeholder — wire up to API when endpoint is ready
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
await updateProfile({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
nick_name: nickName,
|
||||
preferred_language: language,
|
||||
});
|
||||
toast.success("Profile settings saved");
|
||||
} catch {
|
||||
toast.error("Failed to save profile settings.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle } from "lucide-react"
|
||||
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare } from "lucide-react"
|
||||
import practiceSrc from "../../assets/Practice.svg"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
|
|
@ -16,8 +16,8 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table"
|
||||
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
||||
import type { Course, CourseCategory } from "../../types/course.types"
|
||||
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse, getRatings } from "../../api/courses.api"
|
||||
import type { Course, CourseCategory, Rating } from "../../types/course.types"
|
||||
|
||||
export function CoursesPage() {
|
||||
const { categoryId } = useParams<{ categoryId: string }>()
|
||||
|
|
@ -43,6 +43,10 @@ export function CoursesPage() {
|
|||
const [editThumbnail, setEditThumbnail] = useState("")
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateError, setUpdateError] = useState<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 () => {
|
||||
if (!categoryId) return
|
||||
|
|
@ -212,6 +216,20 @@ export function CoursesPage() {
|
|||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses`)
|
||||
}
|
||||
|
||||
const handleViewRatings = async (courseId: number) => {
|
||||
setRatingsCourseId(courseId)
|
||||
setShowRatingsModal(true)
|
||||
setCourseRatingsLoading(true)
|
||||
try {
|
||||
const res = await getRatings({ target_type: "course", target_id: courseId, limit: 10 })
|
||||
setCourseRatings(res.data.data ?? [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch ratings:", err)
|
||||
} finally {
|
||||
setCourseRatingsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
|
|
@ -332,6 +350,17 @@ export function CoursesPage() {
|
|||
</TableCell>
|
||||
<TableCell className="py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -542,6 +571,120 @@ export function CoursesPage() {
|
|||
</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 */}
|
||||
{showDeleteModal && courseToDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical } from "lucide-react"
|
||||
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play, Loader2 } from "lucide-react"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import { Card } from "../../components/ui/card"
|
||||
import alertSrc from "../../assets/Alert.svg"
|
||||
|
|
@ -15,11 +15,14 @@ import {
|
|||
deleteQuestionSet,
|
||||
createVimeoVideo,
|
||||
updateSubCourseVideo,
|
||||
deleteSubCourseVideo
|
||||
deleteSubCourseVideo,
|
||||
getRatings,
|
||||
getVimeoSample,
|
||||
} from "../../api/courses.api"
|
||||
import type { SubCourse, QuestionSet, SubCourseVideo } from "../../types/course.types"
|
||||
import type { SubCourse, QuestionSet, SubCourseVideo, Rating, VimeoSampleVideo } from "../../types/course.types"
|
||||
import { Select } from "../../components/ui/select"
|
||||
|
||||
type TabType = "video" | "practice"
|
||||
type TabType = "video" | "practice" | "ratings"
|
||||
type StatusFilter = "all" | "published" | "draft" | "archived"
|
||||
|
||||
export function SubCourseContentPage() {
|
||||
|
|
@ -62,12 +65,27 @@ export function SubCourseContentPage() {
|
|||
const [deletingVideo, setDeletingVideo] = useState(false)
|
||||
const [openVideoMenuId, setOpenVideoMenuId] = useState<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 [videoDescription, setVideoDescription] = useState("")
|
||||
const [videoUrl, setVideoUrl] = useState("")
|
||||
const [videoFileSize, setVideoFileSize] = 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(() => {
|
||||
const fetchData = async () => {
|
||||
if (!subCourseId || !courseId) return
|
||||
|
|
@ -115,14 +133,40 @@ export function SubCourseContentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchRatings = async (offset = 0) => {
|
||||
if (!subCourseId) return
|
||||
setRatingsLoading(true)
|
||||
try {
|
||||
const res = await getRatings({
|
||||
target_type: "sub_course",
|
||||
target_id: Number(subCourseId),
|
||||
limit: ratingsPageSize,
|
||||
offset,
|
||||
})
|
||||
setRatings(res.data.data ?? [])
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch ratings:", err)
|
||||
} finally {
|
||||
setRatingsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "practice") {
|
||||
fetchPractices()
|
||||
} else {
|
||||
} else if (activeTab === "video") {
|
||||
fetchVideos()
|
||||
} else if (activeTab === "ratings") {
|
||||
fetchRatings(ratingsPage * ratingsPageSize)
|
||||
}
|
||||
}, [activeTab, subCourseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "ratings") {
|
||||
fetchRatings(ratingsPage * ratingsPageSize)
|
||||
}
|
||||
}, [ratingsPage])
|
||||
|
||||
const handleAddPractice = () => {
|
||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}/add-practice`)
|
||||
}
|
||||
|
|
@ -277,6 +321,47 @@ export function SubCourseContentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Preview a video card via Vimeo sample API
|
||||
const handlePreviewVideo = async (video: SubCourseVideo) => {
|
||||
const idMatch = video.video_url?.match(/(\d{5,})/)
|
||||
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
||||
setShowPreviewModal(true)
|
||||
setPreviewLoading(true)
|
||||
setPreviewIframe("")
|
||||
setPreviewVideo(null)
|
||||
try {
|
||||
const res = await getVimeoSample(vimeoId)
|
||||
setPreviewIframe(res.data.data.iframe)
|
||||
setPreviewVideo(res.data.data.video)
|
||||
} catch {
|
||||
setPreviewIframe("")
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Preview inside add/edit modal from a sample vimeo ID picker
|
||||
const handleModalPreview = async (vimeoId: string) => {
|
||||
if (!vimeoId) {
|
||||
setModalPreviewIframe("")
|
||||
return
|
||||
}
|
||||
setModalPreviewLoading(true)
|
||||
try {
|
||||
const res = await getVimeoSample(vimeoId)
|
||||
setModalPreviewIframe(res.data.data.iframe)
|
||||
// Auto-fill fields from vimeo metadata
|
||||
const v = res.data.data.video
|
||||
if (!videoTitle) setVideoTitle(v.name)
|
||||
if (!videoDescription) setVideoDescription(v.description?.slice(0, 200) ?? "")
|
||||
if (!videoDuration) setVideoDuration(v.duration)
|
||||
} catch {
|
||||
setModalPreviewIframe("")
|
||||
} finally {
|
||||
setModalPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPractices = practices.filter((practice) => {
|
||||
if (statusFilter === "all") return true
|
||||
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||
|
|
@ -374,6 +459,19 @@ export function SubCourseContentPage() {
|
|||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</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>
|
||||
|
||||
|
|
@ -569,15 +667,25 @@ export function SubCourseContentPage() {
|
|||
{/* Title */}
|
||||
<h3 className="font-semibold leading-snug text-grayScale-900 line-clamp-2">{video.title}</h3>
|
||||
|
||||
{/* Edit button */}
|
||||
{/* Edit / Preview buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-grayScale-200 text-grayScale-700 transition-colors hover:border-grayScale-300 hover:bg-grayScale-50"
|
||||
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 className="mr-1.5 h-4 w-4" />
|
||||
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 */}
|
||||
<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 */}
|
||||
{showDeleteModal && practiceToDelete && (
|
||||
<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 */}
|
||||
{showAddVideoModal && (
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
|
||||
<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"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</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">
|
||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||
<Input
|
||||
|
|
@ -855,6 +1123,48 @@ export function SubCourseContentPage() {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@ import { useEffect, useMemo, useState } from "react"
|
|||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
|
||||
Loader2, AlertCircle, Eye, X,
|
||||
Loader2, AlertCircle, Eye, X, Pencil, Check,
|
||||
} from "lucide-react"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from "../../components/ui/dialog"
|
||||
import { getRoles, getRoleDetail } from "../../api/rbac.api"
|
||||
import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateRole } from "../../api/rbac.api"
|
||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -34,6 +35,20 @@ export function RolesListPage() {
|
|||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// Role info editing state
|
||||
const [editingRole, setEditingRole] = useState(false)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editDescription, setEditDescription] = useState("")
|
||||
const [savingRole, setSavingRole] = useState(false)
|
||||
|
||||
// Permissions editing state
|
||||
const [editingPermissions, setEditingPermissions] = useState(false)
|
||||
const [allPermissionsMap, setAllPermissionsMap] = useState<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
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
|
@ -81,6 +96,105 @@ export function RolesListPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Enter role info edit mode
|
||||
const handleEditRole = () => {
|
||||
if (!selectedRole) return
|
||||
setEditName(selectedRole.name)
|
||||
setEditDescription(selectedRole.description)
|
||||
setEditingRole(true)
|
||||
}
|
||||
|
||||
const handleCancelEditRole = () => {
|
||||
setEditingRole(false)
|
||||
}
|
||||
|
||||
const handleSaveRole = async () => {
|
||||
if (!selectedRole || !editName.trim()) return
|
||||
setSavingRole(true)
|
||||
try {
|
||||
await updateRole(selectedRole.id, {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim(),
|
||||
})
|
||||
const res = await getRoleDetail(selectedRole.id)
|
||||
setSelectedRole(res.data.data)
|
||||
setEditingRole(false)
|
||||
toast.success("Role updated successfully.")
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
"Failed to update role."
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSavingRole(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter edit mode – fetch all permissions
|
||||
const handleEditPermissions = async () => {
|
||||
setEditingPermissions(true)
|
||||
setPermSearch("")
|
||||
setSelectedPermissionIds(new Set(selectedRole?.permissions.map((p) => p.id) ?? []))
|
||||
|
||||
if (Object.keys(allPermissionsMap).length === 0) {
|
||||
setPermLoading(true)
|
||||
try {
|
||||
const res = await getAllPermissions()
|
||||
setAllPermissionsMap(res.data.data ?? {})
|
||||
} catch {
|
||||
toast.error("Failed to load permissions.")
|
||||
setEditingPermissions(false)
|
||||
} finally {
|
||||
setPermLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingPermissions(false)
|
||||
setPermSearch("")
|
||||
}
|
||||
|
||||
const togglePermission = (id: number) => {
|
||||
setSelectedPermissionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleGroup = (perms: RolePermission[]) => {
|
||||
const allSelected = perms.every((p) => selectedPermissionIds.has(p.id))
|
||||
setSelectedPermissionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const p of perms) {
|
||||
if (allSelected) next.delete(p.id)
|
||||
else next.add(p.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (!selectedRole) return
|
||||
setSavingPermissions(true)
|
||||
try {
|
||||
await setRolePermissions(selectedRole.id, {
|
||||
permission_ids: Array.from(selectedPermissionIds),
|
||||
})
|
||||
// Refresh role detail
|
||||
const res = await getRoleDetail(selectedRole.id)
|
||||
setSelectedRole(res.data.data)
|
||||
setEditingPermissions(false)
|
||||
toast.success("Permissions updated successfully.")
|
||||
} catch {
|
||||
toast.error("Failed to update permissions.")
|
||||
} finally {
|
||||
setSavingPermissions(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Group permissions by group_name
|
||||
const permissionGroups = useMemo(() => {
|
||||
if (!selectedRole?.permissions) return []
|
||||
|
|
@ -93,6 +207,24 @@ export function RolesListPage() {
|
|||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [selectedRole])
|
||||
|
||||
// Filtered permission groups for edit mode
|
||||
const editPermissionGroups = useMemo(() => {
|
||||
const q = permSearch.toLowerCase()
|
||||
const entries: [string, RolePermission[]][] = []
|
||||
for (const [groupName, perms] of Object.entries(allPermissionsMap)) {
|
||||
const filtered = q
|
||||
? perms.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.key.toLowerCase().includes(q) ||
|
||||
groupName.toLowerCase().includes(q),
|
||||
)
|
||||
: perms
|
||||
if (filtered.length > 0) entries.push([groupName, filtered])
|
||||
}
|
||||
return entries.sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [allPermissionsMap, permSearch])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
return (
|
||||
|
|
@ -175,7 +307,7 @@ export function RolesListPage() {
|
|||
className={cn(
|
||||
"h-1.5",
|
||||
role.is_system
|
||||
? "bg-gradient-to-r from-amber-400 to-amber-500"
|
||||
? "bg-gradient-to-r from-brand-400 to-brand-600"
|
||||
: "bg-gradient-to-r from-brand-500 to-brand-600",
|
||||
)}
|
||||
/>
|
||||
|
|
@ -186,7 +318,7 @@ export function RolesListPage() {
|
|||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg",
|
||||
role.is_system
|
||||
? "bg-amber-50 text-amber-600"
|
||||
? "bg-brand-100 text-brand-600"
|
||||
: "bg-brand-50 text-brand-600",
|
||||
)}
|
||||
>
|
||||
|
|
@ -265,20 +397,89 @@ export function RolesListPage() {
|
|||
)}
|
||||
|
||||
{/* Role detail dialog */}
|
||||
<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">
|
||||
<DialogHeader>
|
||||
{!editingRole ? (
|
||||
<>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedRole?.is_system ? (
|
||||
<ShieldCheck className="h-5 w-5 text-amber-500" />
|
||||
<ShieldCheck className="h-5 w-5 text-brand-500" />
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-brand-500" />
|
||||
)}
|
||||
{selectedRole?.name ?? "Role Details"}
|
||||
{selectedRole && (
|
||||
<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>
|
||||
|
||||
{detailLoading && (
|
||||
|
|
@ -302,9 +503,26 @@ export function RolesListPage() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Permissions grouped */}
|
||||
{/* Permissions section */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-semibold text-grayScale-600">Permissions</h4>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-grayScale-600">Permissions</h4>
|
||||
{!editingPermissions && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={handleEditPermissions}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit Permissions
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* VIEW mode */}
|
||||
{!editingPermissions && (
|
||||
<>
|
||||
{permissionGroups.length === 0 ? (
|
||||
<p className="text-xs italic text-grayScale-400">No permissions assigned.</p>
|
||||
) : (
|
||||
|
|
@ -329,6 +547,142 @@ export function RolesListPage() {
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Search,
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
SlidersHorizontal,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
|
|
@ -20,7 +21,7 @@ import {
|
|||
} from "../../components/ui/table";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||
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";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
|
|
@ -88,6 +89,10 @@ export function TeamManagementPage() {
|
|||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
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(() => {
|
||||
const fetchMembers = async () => {
|
||||
|
|
@ -133,7 +138,45 @@ export function TeamManagementPage() {
|
|||
};
|
||||
|
||||
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 (
|
||||
|
|
@ -372,6 +415,46 @@ export function TeamManagementPage() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,47 +2,19 @@ import { useEffect, useState } from "react";
|
|||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Mail,
|
||||
Phone,
|
||||
MessageCircle,
|
||||
Shield,
|
||||
User,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { getTeamMemberById } from "../../api/team.api";
|
||||
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 {
|
||||
return role
|
||||
.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() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-grayScale-100" />
|
||||
<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="rounded-2xl bg-grayScale-100 h-52" />
|
||||
<div className="rounded-2xl bg-grayScale-100 h-52" />
|
||||
<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-96" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -157,33 +141,153 @@ export function TeamMemberDetailPage() {
|
|||
Back to Team
|
||||
</Link>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-28 bg-gradient-to-r from-brand-600 via-brand-400 to-mint-500" />
|
||||
<CardContent className="-mt-12 px-4 sm:px-8 pb-4 sm:pb-8 pt-0">
|
||||
<div className="flex flex-col items-start gap-5 sm:flex-row sm:items-end">
|
||||
<Avatar className="h-24 w-24 ring-4 ring-white shadow-soft">
|
||||
{/* Hero Banner */}
|
||||
<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">
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h1 className="text-3xl font-bold text-white sm:text-4xl">
|
||||
Hello {member.first_name}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/70">
|
||||
This is the profile page. You can see the progress made with their
|
||||
work and manage their projects or assigned tasks
|
||||
</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>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Left: My Account Card */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-lg">My account</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-brand-600 px-5 hover:bg-brand-500"
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* User Information */}
|
||||
<div>
|
||||
<h4 className="mb-4 text-xs font-semibold uppercase tracking-wider text-grayScale-400">
|
||||
User Information
|
||||
</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-2xl font-bold">
|
||||
<AvatarFallback className="bg-brand-100 text-brand-600 text-3xl font-bold">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-1">
|
||||
<h1 className="text-2xl font-bold text-grayScale-600">{fullName}</h1>
|
||||
<p className="mt-0.5 text-sm text-grayScale-400">{member.job_title} · {member.department}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<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
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
getRoleBadgeClasses(member.team_role)
|
||||
)}
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
{formatRoleLabel(member.team_role)}
|
||||
</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.5 py-0.5 text-xs font-medium",
|
||||
"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"
|
||||
|
|
@ -197,136 +301,17 @@ export function TeamMemberDetailPage() {
|
|||
/>
|
||||
{member.status === "active" ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<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">
|
||||
</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)}
|
||||
</span>
|
||||
</p>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
Users,
|
||||
|
|
@ -7,10 +8,32 @@ import {
|
|||
ArrowRight,
|
||||
List,
|
||||
UsersRound,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
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() {
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
|
|
@ -30,7 +53,9 @@ export function UserManagementDashboard() {
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -42,7 +67,9 @@ export function UserManagementDashboard() {
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -54,7 +81,9 @@ export function UserManagementDashboard() {
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -501,3 +501,58 @@ export interface ReorderItem {
|
|||
sub_course_id: 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,3 +110,35 @@ export interface UserProfileResponse {
|
|||
data: UserProfileData
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user