Compare commits
No commits in common. "95f5d37878cfeb9e88876fe6bfcc6f885317a6e0" and "28f7ac2dcd65a6e0db67b0eb57490f53dec01fe5" have entirely different histories.
95f5d37878
...
28f7ac2dcd
19
package-lock.json
generated
19
package-lock.json
generated
|
|
@ -88,6 +88,7 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -2753,6 +2754,7 @@
|
|||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
|
|
@ -2763,6 +2765,7 @@
|
|||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -2773,6 +2776,7 @@
|
|||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -2828,6 +2832,7 @@
|
|||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
|
|
@ -3079,6 +3084,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -3317,6 +3323,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -3893,6 +3900,7 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -5007,6 +5015,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5054,6 +5063,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -5242,6 +5252,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -5251,6 +5262,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -5270,6 +5282,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -5475,7 +5488,8 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -5879,6 +5893,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -6046,6 +6061,7 @@
|
|||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -6183,6 +6199,7 @@
|
|||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,15 +37,11 @@ import type {
|
|||
CreateQuestionRequest,
|
||||
CreateQuestionResponse,
|
||||
CreateVimeoVideoRequest,
|
||||
CreateCourseCategoryRequest,
|
||||
} from "../types/course.types"
|
||||
|
||||
export const getCourseCategories = () =>
|
||||
http.get<GetCourseCategoriesResponse>("/course-management/categories")
|
||||
|
||||
export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
||||
http.post("/course-management/categories", data)
|
||||
|
||||
export const getCoursesByCategory = (categoryId: number) =>
|
||||
http.get<GetCoursesResponse>(`/course-management/categories/${categoryId}/courses`)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import http from "./http"
|
||||
import type { GetTeamMembersResponse, GetTeamMemberResponse, CreateTeamMemberRequest } from "../types/team.types"
|
||||
import type { GetTeamMembersResponse, GetTeamMemberResponse } from "../types/team.types"
|
||||
|
||||
export const getTeamMembers = (page?: number, pageSize?: number) =>
|
||||
http.get<GetTeamMembersResponse>("/team/members", {
|
||||
|
|
@ -11,6 +11,3 @@ export const getTeamMembers = (page?: number, pageSize?: number) =>
|
|||
|
||||
export const getTeamMemberById = (id: number) =>
|
||||
http.get<GetTeamMemberResponse>(`/team/members/${id}`)
|
||||
|
||||
export const createTeamMember = (data: CreateTeamMemberRequest) =>
|
||||
http.post("/team/register", data)
|
||||
|
|
|
|||
|
|
@ -14,17 +14,3 @@ export const getUserById = (id: number) =>
|
|||
|
||||
export const getMyProfile = () =>
|
||||
http.get<UserProfileResponse>("/team/me");
|
||||
|
||||
// Best-guess API for creating a new user (admin-side).
|
||||
// Adjust payload shape or endpoint if backend differs.
|
||||
export interface CreateUserRequest {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
role: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const createUser = (payload: CreateUserRequest) =>
|
||||
http.post("/users", payload);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { DashboardPage } from "../pages/DashboardPage"
|
|||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
||||
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
|
||||
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"
|
||||
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"
|
||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
||||
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
||||
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
||||
|
|
@ -35,7 +33,6 @@ import { IssuesPage } from "../pages/issues/IssuesPage"
|
|||
import { ProfilePage } from "../pages/ProfilePage"
|
||||
import { SettingsPage } from "../pages/SettingsPage"
|
||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
||||
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage"
|
||||
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
||||
import { LoginPage } from "../pages/auth/LoginPage"
|
||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
|
||||
|
|
@ -65,8 +62,6 @@ export function AppRoutes() {
|
|||
|
||||
<Route path="/content" element={<ContentManagementLayout />}>
|
||||
<Route index element={<CourseCategoryPage />} />
|
||||
<Route path="courses" element={<AllCoursesPage />} />
|
||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||
{/* Course → Sub-course → Video/Practice */}
|
||||
|
|
@ -90,7 +85,6 @@ export function AppRoutes() {
|
|||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
|
||||
<Route path="/team" element={<TeamManagementPage />} />
|
||||
<Route path="/team/add" element={<AddTeamMemberPage />} />
|
||||
<Route path="/team/:id" element={<TeamMemberDetailPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
import { StatCard } from "../components/dashboard/StatCard"
|
||||
import alertSrc from "../assets/Alert.svg"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||
import { cn } from "../lib/utils"
|
||||
// import { cn } from "../lib/utils"
|
||||
import { getTeamMemberById } from "../api/team.api"
|
||||
import { getDashboard } from "../api/analytics.api"
|
||||
import { useEffect, useState } from "react"
|
||||
|
|
@ -46,7 +46,6 @@ export function DashboardPage() {
|
|||
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
|
|
@ -124,45 +123,8 @@ export function DashboardPage() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stat tabs */}
|
||||
<div className="mb-3 border-b border-grayScale-200">
|
||||
<div className="-mb-px flex gap-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStatTab("primary")}
|
||||
className={cn(
|
||||
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
|
||||
activeStatTab === "primary"
|
||||
? "text-brand-600"
|
||||
: "text-grayScale-400 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Overview
|
||||
{activeStatTab === "primary" && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStatTab("secondary")}
|
||||
className={cn(
|
||||
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
|
||||
activeStatTab === "secondary"
|
||||
? "text-brand-600"
|
||||
: "text-grayScale-400 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
More metrics
|
||||
{activeStatTab === "secondary" && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
{activeStatTab === "primary" && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Total Users"
|
||||
|
|
@ -192,11 +154,9 @@ export function DashboardPage() {
|
|||
deltaPositive={dashboard.issues.resolution_rate > 0.5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary Stats */}
|
||||
{activeStatTab === "secondary" && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon={BookOpen}
|
||||
label="Courses"
|
||||
|
|
@ -226,7 +186,6 @@ export function DashboardPage() {
|
|||
deltaPositive
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Registrations Chart */}
|
||||
<div className="mt-5 grid gap-4">
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ 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="mx-auto w-full max-w-5xl space-y-8 px-4 py-10 sm:px-6">
|
||||
<div className="animate-pulse space-y-8">
|
||||
{/* Hero skeleton */}
|
||||
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
|
||||
|
|
@ -93,15 +93,15 @@ function InfoRow({
|
|||
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="group flex items-center justify-between rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60">
|
||||
<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>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
|
||||
<span className="text-right">{value || "—"}</span>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -121,13 +121,13 @@ 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-11 w-11 -rotate-90" viewBox="0 0 44 44">
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
|
|
@ -150,7 +150,7 @@ function ProgressRing({ percent }: { percent: number }) {
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -179,7 +179,7 @@ export function ProfilePage() {
|
|||
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6">
|
||||
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
|
||||
<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">
|
||||
|
|
@ -203,247 +203,228 @@ export function ProfilePage() {
|
|||
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
|
||||
const completionPct = profile.profile_completion_percentage ?? 0;
|
||||
|
||||
const sectionCardIcons: Record<string, { icon: typeof User; color: string }> = {
|
||||
personal: { icon: User, color: "from-brand-500 to-brand-600" },
|
||||
contact: { icon: Mail, color: "from-brand-400 to-brand-500" },
|
||||
account: { icon: Shield, color: "from-brand-600 to-brand-500" },
|
||||
};
|
||||
|
||||
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 className="mx-auto w-full max-w-5xl space-y-8 px-4 py-8 sm:px-6">
|
||||
{/* Hero Card */}
|
||||
<Card className="overflow-hidden border-0 shadow-lg">
|
||||
{/* Banner gradient */}
|
||||
<div className="relative h-36 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 sm:h-40">
|
||||
{/* Decorative pattern overlay */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 25% 50%, white 1px, transparent 1px), radial-gradient(circle at 75% 50%, white 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Bottom fade */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* 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.
|
||||
</p>
|
||||
</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">
|
||||
<CardContent className="-mt-16 px-6 pb-8 pt-0 sm:px-10">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Avatar */}
|
||||
<Avatar className="h-28 w-28 ring-4 ring-white shadow-lg">
|
||||
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
|
||||
<AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600">
|
||||
<AvatarFallback className="bg-gradient-to-br from-brand-100 to-brand-200 text-2xl font-bold text-brand-600">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2>
|
||||
<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">
|
||||
|
||||
{/* Name */}
|
||||
<h1 className="mt-4 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
|
||||
{fullName}
|
||||
</h1>
|
||||
|
||||
{/* Role badge */}
|
||||
<Badge
|
||||
className={cn(
|
||||
"px-2.5 py-0.5 text-xs font-semibold",
|
||||
"mt-2.5 px-3 py-1",
|
||||
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-100 text-grayScale-600 border border-grayScale-200"
|
||||
)}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
<Shield className="h-3 w-3 mr-1.5" />
|
||||
{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
|
||||
|
||||
{/* Status pills */}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5">
|
||||
{/* Active status */}
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
||||
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
|
||||
profile.status === "ACTIVE"
|
||||
? "bg-mint-50 text-mint-600"
|
||||
: "bg-destructive/10 text-destructive"
|
||||
? "border-mint-300 bg-mint-100/60 text-mint-500"
|
||||
: "border-destructive/20 bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
|
||||
"h-2 w-2 rounded-full",
|
||||
profile.status === "ACTIVE" ? "bg-mint-500 animate-pulse" : "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">
|
||||
</div>
|
||||
|
||||
{/* Email verification */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
|
||||
profile.email_verified
|
||||
? "border-mint-300 bg-mint-100/60 text-mint-500"
|
||||
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
|
||||
)}
|
||||
>
|
||||
{profile.email_verified ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
Email {profile.email_verified ? "Verified" : "Unverified"}
|
||||
</div>
|
||||
|
||||
{/* Phone verification */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
|
||||
profile.phone_verified
|
||||
? "border-mint-300 bg-mint-100/60 text-mint-500"
|
||||
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
|
||||
)}
|
||||
>
|
||||
{profile.phone_verified ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
Phone {profile.phone_verified ? "Verified" : "Unverified"}
|
||||
</div>
|
||||
|
||||
{/* Profile completion ring */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-brand-200 bg-brand-100/30 px-3 py-1 text-xs font-semibold text-brand-600">
|
||||
<ProgressRing percent={completionPct} />
|
||||
<span>Profile complete</span>
|
||||
</div>
|
||||
<span>Profile Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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
|
||||
icon={MapPin}
|
||||
label="Location"
|
||||
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
|
||||
/>
|
||||
</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">
|
||||
{/* Info Cards */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Personal Information */}
|
||||
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-600">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-grayScale-700">
|
||||
Last login
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-400">
|
||||
{formatDateTime(profile.last_login)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-500">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
|
||||
<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>
|
||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0.5 px-3 pb-4">
|
||||
<InfoRow icon={User} label="Full Name" value={fullName} />
|
||||
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
|
||||
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
|
||||
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
|
||||
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Account summary */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Account
|
||||
</h3>
|
||||
<Card className="shadow-none border-grayScale-100">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-grayScale-400">Role</span>
|
||||
<span className="font-medium text-grayScale-700">{profile.role}</span>
|
||||
{/* Contact & Location */}
|
||||
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
|
||||
<Mail className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-grayScale-400">Status</span>
|
||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||
Contact & Location
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0.5 px-3 pb-4">
|
||||
<InfoRow
|
||||
icon={Mail}
|
||||
label="Email"
|
||||
value={profile.email}
|
||||
extra={<VerifiedIcon verified={profile.email_verified} />}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Phone}
|
||||
label="Phone"
|
||||
value={profile.phone_number}
|
||||
extra={<VerifiedIcon verified={profile.phone_verified} />}
|
||||
/>
|
||||
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
|
||||
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Details */}
|
||||
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||
Account Details
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0.5 px-3 pb-4">
|
||||
<InfoRow icon={Shield} label="Role" value={profile.role} />
|
||||
<InfoRow
|
||||
icon={Languages}
|
||||
label="Language"
|
||||
value={profile.preferred_language || "—"}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Clock}
|
||||
label="Last Login"
|
||||
value={formatDateTime(profile.last_login)}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Calendar}
|
||||
label="Member Since"
|
||||
value={formatDate(profile.created_at)}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={CheckCircle2}
|
||||
label="Status"
|
||||
value={profile.status}
|
||||
extra={
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs font-semibold",
|
||||
"h-2.5 w-2.5 rounded-full ring-2",
|
||||
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"
|
||||
? "bg-mint-500 ring-mint-100"
|
||||
: "bg-destructive ring-destructive/20"
|
||||
)}
|
||||
/>
|
||||
{profile.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-grayScale-400">Email</span>
|
||||
<span className="flex items-center gap-1 text-grayScale-700">
|
||||
<span className="truncate max-w-[140px] text-right text-xs sm:text-sm">
|
||||
{profile.email}
|
||||
</span>
|
||||
<VerifiedIcon verified={profile.email_verified} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-grayScale-400">Phone</span>
|
||||
<span className="flex items-center gap-1 text-grayScale-700">
|
||||
<span className="truncate max-w-[120px] text-right text-xs sm:text-sm">
|
||||
{profile.phone_number || "—"}
|
||||
</span>
|
||||
<VerifiedIcon verified={profile.phone_verified} />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,7 +284,6 @@ export function AnalyticsPage() {
|
|||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -391,51 +390,7 @@ export function AnalyticsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Tabs */}
|
||||
<div className="mb-4 border-b border-grayScale-200">
|
||||
<div className="-mb-px flex gap-6">
|
||||
<button
|
||||
onClick={() => setActiveSummaryTab("key")}
|
||||
className={cn(
|
||||
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
|
||||
activeSummaryTab === "key" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Key Metrics
|
||||
{activeSummaryTab === "key" && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSummaryTab("content")}
|
||||
className={cn(
|
||||
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
|
||||
activeSummaryTab === "content" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Content & Platform
|
||||
{activeSummaryTab === "content" && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSummaryTab("operations")}
|
||||
className={cn(
|
||||
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
|
||||
activeSummaryTab === "operations" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Operations
|
||||
{activeSummaryTab === "operations" && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{activeSummaryTab === "key" && (
|
||||
<>
|
||||
{/* ─── Key Metrics ─── */}
|
||||
<Section title="Key Metrics" icon={TrendingUp} defaultOpen>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -469,18 +424,9 @@ export function AnalyticsPage() {
|
|||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeSummaryTab === "content" && (
|
||||
<>
|
||||
{/* ─── Content & Platform ─── */}
|
||||
<Section
|
||||
title="Content & Platform"
|
||||
icon={BookOpen}
|
||||
count={courses.total_courses + content.total_questions}
|
||||
defaultOpen
|
||||
>
|
||||
<Section title="Content & Platform" icon={BookOpen} count={courses.total_courses + content.total_questions} defaultOpen>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
icon={FolderOpen}
|
||||
|
|
@ -511,11 +457,7 @@ export function AnalyticsPage() {
|
|||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeSummaryTab === "operations" && (
|
||||
<>
|
||||
{/* ─── Operations ─── */}
|
||||
<Section title="Operations" icon={Bell} defaultOpen>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -549,8 +491,6 @@ export function AnalyticsPage() {
|
|||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── User Analytics ─── */}
|
||||
<Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from "react"
|
||||
import { useNavigate, useParams } from "react-router-dom"
|
||||
import { ArrowLeft, Plus, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -106,44 +105,32 @@ export function AddQuestionPage() {
|
|||
|
||||
// Validation
|
||||
if (!formData.question.trim()) {
|
||||
toast.error("Missing question", {
|
||||
description: "Please enter a question before saving.",
|
||||
})
|
||||
alert("Please enter a question")
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.type === "multiple-choice" || formData.type === "true-false") {
|
||||
if (!formData.correctAnswer) {
|
||||
toast.error("Missing correct answer", {
|
||||
description: "Select the correct answer for this question.",
|
||||
})
|
||||
alert("Please select a correct answer")
|
||||
return
|
||||
}
|
||||
if (formData.type === "multiple-choice") {
|
||||
const hasEmptyOptions = formData.options.some((opt) => !opt.trim())
|
||||
if (hasEmptyOptions) {
|
||||
toast.error("Incomplete options", {
|
||||
description: "Fill in all answer options for this multiple choice question.",
|
||||
})
|
||||
alert("Please fill in all options")
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if (formData.type === "short-answer") {
|
||||
if (!formData.correctAnswer.trim()) {
|
||||
toast.error("Missing correct answer", {
|
||||
description: "Enter the expected correct answer.",
|
||||
})
|
||||
alert("Please enter a correct answer")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// In a real app, save the question here
|
||||
console.log("Saving question:", formData)
|
||||
toast.success(isEditing ? "Question updated" : "Question created", {
|
||||
description: isEditing
|
||||
? "The question has been updated successfully."
|
||||
: "Your new question has been created.",
|
||||
})
|
||||
alert(isEditing ? "Question updated successfully!" : "Question created successfully!")
|
||||
navigate("/content/questions")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,584 +0,0 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Search, Plus, RefreshCw, Edit2, ToggleLeft, ToggleRight } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { getCourseCategories, getCoursesByCategory, createCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
||||
import type { Course, CourseCategory } from "../../types/course.types"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type CourseWithCategory = Course & { category_name: string }
|
||||
|
||||
export function AllCoursesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [courses, setCourses] = useState<CourseWithCategory[]>([])
|
||||
const [categories, setCategories] = useState<CourseCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all")
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createCategoryId, setCreateCategoryId] = useState<string>("")
|
||||
const [createSubCategoryId, setCreateSubCategoryId] = useState<string>("")
|
||||
const [createTitle, setCreateTitle] = useState("")
|
||||
const [createDescription, setCreateDescription] = useState("")
|
||||
const [createThumbnail, setCreateThumbnail] = useState<File | null>(null)
|
||||
const [createVideo, setCreateVideo] = useState<File | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [courseToEdit, setCourseToEdit] = useState<CourseWithCategory | null>(null)
|
||||
const [editTitle, setEditTitle] = useState("")
|
||||
const [editDescription, setEditDescription] = useState("")
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
const fetchAllCourses = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const categoriesRes = await getCourseCategories()
|
||||
const cats = categoriesRes.data.data.categories ?? []
|
||||
setCategories(cats)
|
||||
|
||||
const allCourses: CourseWithCategory[] = []
|
||||
for (const cat of cats) {
|
||||
const res = await getCoursesByCategory(cat.id)
|
||||
const catCourses = res.data.data.courses ?? []
|
||||
allCourses.push(
|
||||
...catCourses.map((c) => ({
|
||||
...c,
|
||||
category_name: cat.name,
|
||||
})),
|
||||
)
|
||||
}
|
||||
setCourses(allCourses)
|
||||
} catch (err) {
|
||||
console.error("Failed to load courses:", err)
|
||||
setError("Failed to load courses")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllCourses()
|
||||
}, [])
|
||||
|
||||
const filteredCourses = courses.filter((course) => {
|
||||
if (categoryFilter !== "all" && String(course.category_id) !== categoryFilter) {
|
||||
return false
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase()
|
||||
const haystack = `${course.title} ${course.description} ${course.category_name}`.toLowerCase()
|
||||
if (!haystack.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
const effectiveCategoryId = createSubCategoryId || createCategoryId
|
||||
|
||||
if (!effectiveCategoryId || !createTitle.trim() || !createDescription.trim()) {
|
||||
toast.error("Missing fields", {
|
||||
description: "Category (or subcategory), title, and description are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await createCourse({
|
||||
category_id: Number(effectiveCategoryId),
|
||||
title: createTitle.trim(),
|
||||
description: createDescription.trim(),
|
||||
})
|
||||
|
||||
toast.success("Course created", {
|
||||
description: `"${createTitle.trim()}" has been created.`,
|
||||
})
|
||||
|
||||
setCreateOpen(false)
|
||||
setCreateCategoryId("")
|
||||
setCreateSubCategoryId("")
|
||||
setCreateTitle("")
|
||||
setCreateDescription("")
|
||||
setCreateThumbnail(null)
|
||||
setCreateVideo(null)
|
||||
await fetchAllCourses()
|
||||
} catch (err: any) {
|
||||
console.error("Failed to create course:", err)
|
||||
toast.error("Failed to create course", {
|
||||
description: err?.response?.data?.message || "Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (course: CourseWithCategory) => {
|
||||
setTogglingId(course.id)
|
||||
try {
|
||||
await updateCourseStatus(course.id, !course.is_active)
|
||||
await fetchAllCourses()
|
||||
} catch (err) {
|
||||
console.error("Failed to update course status:", err)
|
||||
toast.error("Failed to update course status")
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (course: CourseWithCategory) => {
|
||||
setCourseToEdit(course)
|
||||
setEditTitle(course.title)
|
||||
setEditDescription(course.description || "")
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const handleUpdateCourse = async () => {
|
||||
if (!courseToEdit) return
|
||||
if (!editTitle.trim() || !editDescription.trim()) {
|
||||
toast.error("Missing fields", {
|
||||
description: "Title and description are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
await updateCourse(courseToEdit.id, {
|
||||
title: editTitle.trim(),
|
||||
description: editDescription.trim(),
|
||||
})
|
||||
toast.success("Course updated")
|
||||
setEditOpen(false)
|
||||
setCourseToEdit(null)
|
||||
await fetchAllCourses()
|
||||
} catch (err: any) {
|
||||
console.error("Failed to update course:", err)
|
||||
toast.error("Failed to update course", {
|
||||
description: err?.response?.data?.message || "Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all courses…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
|
||||
<div className="rounded-full bg-red-100 p-2">
|
||||
<RefreshCw className="h-5 w-5 shrink-0 text-red-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All Courses</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
View and manage courses across all categories.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Course
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Course Management
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-5">
|
||||
{/* Search / Filters */}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
|
||||
<Input
|
||||
placeholder="Search by title, description, or category…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value as typeof categoryFilter)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-medium text-grayScale-400">
|
||||
Showing {filteredCourses.length} of {courses.length} courses
|
||||
</div>
|
||||
|
||||
{/* Courses Table */}
|
||||
{filteredCourses.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Course
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Category
|
||||
</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCourses.map((course, index) => (
|
||||
<TableRow
|
||||
key={course.id}
|
||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
|
||||
}`}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TableCell className="max-w-md py-3.5">
|
||||
<div className="truncate text-sm font-semibold text-grayScale-700">
|
||||
{course.title}
|
||||
</div>
|
||||
{course.description && (
|
||||
<div className="mt-1 truncate text-xs text-grayScale-400">
|
||||
{course.description}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5 text-sm text-grayScale-500">
|
||||
{course.category_name}
|
||||
</TableCell>
|
||||
<TableCell className="hidden py-3.5 md:table-cell">
|
||||
<Badge
|
||||
variant={course.is_active ? "success" : "secondary"}
|
||||
className="text-[11px] font-semibold"
|
||||
>
|
||||
{course.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEditDialog(course)
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||
disabled={togglingId === course.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleStatus(course)
|
||||
}}
|
||||
>
|
||||
{course.is_active ? (
|
||||
<ToggleLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ToggleRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs text-brand-500 hover:bg-brand-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(
|
||||
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
|
||||
)
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
|
||||
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
|
||||
<BookOpen className="h-8 w-8 text-grayScale-400" />
|
||||
</div>
|
||||
<p className="text-base font-semibold text-grayScale-600">No courses found</p>
|
||||
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
|
||||
Try adjusting your search or category filter, or create a new course.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create course dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a category, add basic details, and optionally attach a thumbnail and intro
|
||||
video.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="sm:col-span-1">
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Category
|
||||
</label>
|
||||
<Select
|
||||
value={createCategoryId}
|
||||
onChange={(e) => {
|
||||
setCreateCategoryId(e.target.value)
|
||||
setCreateSubCategoryId("")
|
||||
}}
|
||||
>
|
||||
<option value="">Select category</option>
|
||||
{categories
|
||||
.filter((cat) => !cat.parent_id)
|
||||
.map((cat) => (
|
||||
<option key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Subcategory (optional)
|
||||
</label>
|
||||
<Select
|
||||
value={createSubCategoryId}
|
||||
onChange={(e) => setCreateSubCategoryId(e.target.value)}
|
||||
disabled={!createCategoryId}
|
||||
>
|
||||
<option value="">No subcategory</option>
|
||||
{categories
|
||||
.filter((cat) => cat.parent_id && String(cat.parent_id) === createCategoryId)
|
||||
.map((cat) => (
|
||||
<option key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Course title
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Beginner English A1"
|
||||
value={createTitle}
|
||||
onChange={(e) => setCreateTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder="Short summary of what this course covers."
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Thumbnail image
|
||||
</label>
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
onFileSelect={setCreateThumbnail}
|
||||
label="Upload thumbnail"
|
||||
description="JPEG or PNG"
|
||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Intro video
|
||||
</label>
|
||||
<FileUpload
|
||||
accept="video/*"
|
||||
onFileSelect={setCreateVideo}
|
||||
label="Upload video"
|
||||
description="Optional intro or overview"
|
||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
File uploads are currently stored client-side only. Connect your storage/API layer to
|
||||
persist thumbnails and videos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateOpen(false)
|
||||
setCreateCategoryId("")
|
||||
setCreateTitle("")
|
||||
setCreateDescription("")
|
||||
setCreateThumbnail(null)
|
||||
setCreateVideo(null)
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||
disabled={creating}
|
||||
onClick={handleCreateCourse}
|
||||
>
|
||||
{creating ? "Creating…" : "Create course"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit course dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the title and description for this course. Status can be toggled from the
|
||||
table.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Course title
|
||||
</label>
|
||||
<Input
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Enter course title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Short summary of this course."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditOpen(false)
|
||||
setCourseToEdit(null)
|
||||
setEditTitle("")
|
||||
setEditDescription("")
|
||||
}}
|
||||
disabled={updating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||
disabled={updating}
|
||||
onClick={handleUpdateCourse}
|
||||
>
|
||||
{updating ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4,7 +4,6 @@ import { cn } from "../../lib/utils"
|
|||
const tabs = [
|
||||
{ label: "Overview", to: "/content" },
|
||||
{ label: "Courses", to: "/content/courses" },
|
||||
{ label: "Flows", to: "/content/flows" },
|
||||
{ label: "Speaking", to: "/content/speaking" },
|
||||
{ label: "Practice", to: "/content/practices" },
|
||||
{ label: "Questions", to: "/content/questions" },
|
||||
|
|
|
|||
|
|
@ -57,21 +57,9 @@ const contentSections = [
|
|||
},
|
||||
] as const
|
||||
|
||||
type ContentSection = (typeof contentSections)[number]
|
||||
|
||||
export function ContentOverviewPage() {
|
||||
const { categoryId } = useParams<{ categoryId: string }>()
|
||||
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
|
||||
const [dragKey, setDragKey] = useState<string | null>(null)
|
||||
const [flowSteps, setFlowSteps] = useState<
|
||||
{
|
||||
id: string
|
||||
type: "lesson" | "practice" | "exam" | "feedback" | "course" | "speaking" | "new_course"
|
||||
title: string
|
||||
description?: string
|
||||
}[]
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategory = async () => {
|
||||
|
|
@ -89,75 +77,6 @@ export function ContentOverviewPage() {
|
|||
}
|
||||
}, [categoryId])
|
||||
|
||||
// Load category-level flow sequence (if any) from localStorage
|
||||
useEffect(() => {
|
||||
if (!categoryId) {
|
||||
setFlowSteps([])
|
||||
return
|
||||
}
|
||||
const key = `category_flow_${categoryId}`
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) {
|
||||
setFlowSteps(parsed)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall back to default
|
||||
}
|
||||
|
||||
// No explicit flow saved; fall back to an empty sequence
|
||||
setFlowSteps([])
|
||||
}, [categoryId])
|
||||
|
||||
// Load persisted section order from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem("content_sections_order")
|
||||
if (!raw) return
|
||||
const savedKeys: string[] = JSON.parse(raw)
|
||||
const byKey = new Map(contentSections.map((s) => [s.key, s]))
|
||||
const reordered: ContentSection[] = []
|
||||
savedKeys.forEach((k) => {
|
||||
const item = byKey.get(k as ContentSection["key"])
|
||||
if (item) {
|
||||
reordered.push(item)
|
||||
byKey.delete(k as ContentSection["key"])
|
||||
}
|
||||
})
|
||||
// Append any new sections that weren't in saved order
|
||||
byKey.forEach((item) => reordered.push(item))
|
||||
if (reordered.length) {
|
||||
setSections(reordered)
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupted localStorage
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Persist order whenever it changes
|
||||
useEffect(() => {
|
||||
const keys = sections.map((s) => s.key)
|
||||
window.localStorage.setItem("content_sections_order", JSON.stringify(keys))
|
||||
}, [sections])
|
||||
|
||||
const handleDropOn = (targetKey: string) => {
|
||||
if (!dragKey || dragKey === targetKey) return
|
||||
setSections((prev) => {
|
||||
const currentIndex = prev.findIndex((s) => s.key === dragKey)
|
||||
const targetIndex = prev.findIndex((s) => s.key === targetKey)
|
||||
if (currentIndex === -1 || targetIndex === -1) return prev
|
||||
const copy = [...prev]
|
||||
const [moved] = copy.splice(currentIndex, 1)
|
||||
copy.splice(targetIndex, 0, moved)
|
||||
return copy
|
||||
})
|
||||
setDragKey(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header & Breadcrumb */}
|
||||
|
|
@ -201,24 +120,18 @@ export function ContentOverviewPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid (course builder style – draggable sections) */}
|
||||
{/* Cards Grid */}
|
||||
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{sections.map((section) => {
|
||||
{contentSections.map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
key={section.key}
|
||||
to={section.pathFn(categoryId)}
|
||||
className="group"
|
||||
draggable
|
||||
onDragStart={() => setDragKey(section.key)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => handleDropOn(section.key)}
|
||||
>
|
||||
<Link to={section.pathFn(categoryId)} className="block">
|
||||
<Card
|
||||
className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg ${
|
||||
dragKey === section.key ? "ring-2 ring-brand-300" : ""
|
||||
}`}
|
||||
className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg`}
|
||||
style={{
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
|
|
@ -270,92 +183,9 @@ export function ContentOverviewPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Category flow sequence (if defined) */}
|
||||
{flowSteps.length > 0 && (
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Learning flow
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-0.5 text-xs text-grayScale-400">
|
||||
Sequence of lessons, practice, exams, and feedback for this category.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 md:grid md:auto-cols-fr md:grid-flow-col md:overflow-visible">
|
||||
{flowSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex min-w-[200px] flex-col justify-between rounded-xl border border-grayScale-100 bg-white p-3.5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||
step.type === "lesson" && "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-200",
|
||||
step.type === "practice" &&
|
||||
"bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
|
||||
step.type === "exam" &&
|
||||
"bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200",
|
||||
step.type === "feedback" &&
|
||||
"bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200",
|
||||
step.type === "course" &&
|
||||
"bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200",
|
||||
step.type === "speaking" &&
|
||||
"bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-200",
|
||||
step.type === "new_course" &&
|
||||
"bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200",
|
||||
)}
|
||||
>
|
||||
{step.type === "lesson"
|
||||
? "Lesson"
|
||||
: step.type === "practice"
|
||||
? "Practice"
|
||||
: step.type === "exam"
|
||||
? "Exam / Questions"
|
||||
: step.type === "feedback"
|
||||
? "Feedback"
|
||||
: step.type === "course"
|
||||
? "Course"
|
||||
: step.type === "speaking"
|
||||
? "Speaking section"
|
||||
: "New course (category)"}
|
||||
<span className="text-[10px] text-grayScale-400">#{index + 1}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm font-semibold text-grayScale-700 line-clamp-2">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-500 line-clamp-3">
|
||||
{step.description ||
|
||||
(step.type === "exam"
|
||||
? "Place exams and question sets here."
|
||||
: step.type === "feedback"
|
||||
? "Collect feedback or run follow‑up surveys."
|
||||
: step.type === "course"
|
||||
? "Link or add an existing course to this flow."
|
||||
: step.type === "speaking"
|
||||
? "Speaking or oral practice section."
|
||||
: step.type === "new_course"
|
||||
? "Add a new course within this category."
|
||||
: "Configure this step in the flow builder.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,16 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
|
||||
import { FolderOpen, RefreshCw, BookOpen } from "lucide-react"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import alertSrc from "../../assets/Alert.svg"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog"
|
||||
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
|
||||
import { getCourseCategories } from "../../api/courses.api"
|
||||
import type { CourseCategory } from "../../types/course.types"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function CourseCategoryPage() {
|
||||
const [categories, setCategories] = useState<CourseCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
|
||||
const [newSubCategoryName, setNewSubCategoryName] = useState("")
|
||||
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -82,22 +65,12 @@ export function CourseCategoryPage() {
|
|||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Browse and manage your course categories below
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Category
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
|
||||
|
|
@ -150,207 +123,6 @@ export function CourseCategoryPage() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create category dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-brand-500" />
|
||||
<span>Create course category</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new high-level bucket to organize your courses. You can also nest it under an existing parent category.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Category name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Beginner English, Exam Prep"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Parent category (optional)
|
||||
</label>
|
||||
<Select
|
||||
value={parentCategoryId ?? ""}
|
||||
onChange={(e) =>
|
||||
setParentCategoryId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
>
|
||||
<option value="">No parent (top level)</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="mt-1 text-[11px] text-grayScale-400">
|
||||
When left empty, this becomes a parent category. Any sub categories you add on the
|
||||
right will be created under it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-grayScale-600">
|
||||
Sub categories for this category (optional)
|
||||
</p>
|
||||
{pendingSubCategories.length > 0 && (
|
||||
<span className="text-[11px] text-grayScale-400">
|
||||
{pendingSubCategories.length} sub categor
|
||||
{pendingSubCategories.length === 1 ? "y" : "ies"} to create
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g. Grammar basics, Speaking, Exam practice"
|
||||
value={newSubCategoryName}
|
||||
onChange={(e) => setNewSubCategoryName(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
onClick={() => {
|
||||
const name = newSubCategoryName.trim()
|
||||
if (!name) return
|
||||
if (pendingSubCategories.includes(name)) {
|
||||
setNewSubCategoryName("")
|
||||
return
|
||||
}
|
||||
setPendingSubCategories((prev) => [...prev, name])
|
||||
setNewSubCategoryName("")
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[3.5rem] rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 p-2">
|
||||
{pendingSubCategories.length === 0 ? (
|
||||
<p className="text-[11px] leading-relaxed text-grayScale-400">
|
||||
Added sub categories will appear here so you can visually confirm the structure
|
||||
before saving. This is optional.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pendingSubCategories.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="group inline-flex items-center gap-1 rounded-full bg-white px-2 py-0.5 text-[11px] text-grayScale-600 shadow-sm ring-1 ring-grayScale-200 hover:bg-red-50 hover:text-red-600 hover:ring-red-200"
|
||||
onClick={() =>
|
||||
setPendingSubCategories((prev) =>
|
||||
prev.filter((subName) => subName !== name),
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="max-w-[160px] truncate">{name}</span>
|
||||
<span className="text-[10px] text-grayScale-300 group-hover:text-red-400">
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateOpen(false)
|
||||
setNewCategoryName("")
|
||||
setParentCategoryId(null)
|
||||
setNewSubCategoryName("")
|
||||
setPendingSubCategories([])
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||
disabled={creating || !newCategoryName.trim()}
|
||||
onClick={async () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const name = newCategoryName.trim()
|
||||
const parentPayloadId = parentCategoryId ?? null
|
||||
const parentRes = await createCourseCategory({
|
||||
name: newCategoryName.trim(),
|
||||
parent_id: parentPayloadId,
|
||||
})
|
||||
let createdCategoryId: number | null = null
|
||||
try {
|
||||
const data: any = parentRes?.data
|
||||
createdCategoryId =
|
||||
data?.data?.category?.id ??
|
||||
data?.data?.id ??
|
||||
data?.category?.id ??
|
||||
data?.id ??
|
||||
null
|
||||
} catch {
|
||||
createdCategoryId = null
|
||||
}
|
||||
|
||||
if (createdCategoryId && pendingSubCategories.length > 0) {
|
||||
await Promise.all(
|
||||
pendingSubCategories.map((subName) =>
|
||||
createCourseCategory({
|
||||
name: subName,
|
||||
parent_id: createdCategoryId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
toast.success("Category created", {
|
||||
description:
|
||||
pendingSubCategories.length > 0
|
||||
? `"${name}" and ${pendingSubCategories.length} sub categor${
|
||||
pendingSubCategories.length === 1 ? "y" : "ies"
|
||||
} have been added.`
|
||||
: `"${name}" has been added.`,
|
||||
})
|
||||
setNewCategoryName("")
|
||||
setParentCategoryId(null)
|
||||
setNewSubCategoryName("")
|
||||
setPendingSubCategories([])
|
||||
setCreateOpen(false)
|
||||
fetchCategories()
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err?.response?.data?.message ||
|
||||
"Failed to create category. Please try again."
|
||||
toast.error("Could not create category", {
|
||||
description: message,
|
||||
})
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,802 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react"
|
||||
import { GripVertical, RefreshCw } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { getCourseCategories } from "../../api/courses.api"
|
||||
import type { CourseCategory } from "../../types/course.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
type StepType =
|
||||
| "lesson"
|
||||
| "practice"
|
||||
| "exam"
|
||||
| "feedback"
|
||||
| "course"
|
||||
| "speaking"
|
||||
| "new_course"
|
||||
|
||||
type FlowStep = {
|
||||
id: string
|
||||
type: StepType
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const STEP_LABELS: Record<StepType, string> = {
|
||||
lesson: "Lesson",
|
||||
practice: "Practice",
|
||||
exam: "Exam",
|
||||
feedback: "Feedback loop",
|
||||
course: "Course",
|
||||
speaking: "Speaking section",
|
||||
new_course: "New course (category)",
|
||||
}
|
||||
|
||||
const STEP_BADGE: Record<StepType, string> = {
|
||||
lesson: "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-200",
|
||||
practice: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
|
||||
exam: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200",
|
||||
feedback: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200",
|
||||
course: "bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200",
|
||||
speaking: "bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-200",
|
||||
new_course: "bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200",
|
||||
}
|
||||
|
||||
const PARENT_ORDER_KEY = "parent_categories_order"
|
||||
const SUB_ORDER_KEY_PREFIX = "sub_categories_order_"
|
||||
|
||||
export function CourseFlowBuilderPage() {
|
||||
const [categories, setCategories] = useState<CourseCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<"sub" | "parent">("sub")
|
||||
const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<string>("")
|
||||
const [selectedParentCategoryId, setSelectedParentCategoryId] = useState<string>("")
|
||||
const [steps, setSteps] = useState<FlowStep[]>([])
|
||||
const [dragStepId, setDragStepId] = useState<string | null>(null)
|
||||
// Order of parent category ids (scope = parent)
|
||||
const [parentCategoryOrder, setParentCategoryOrder] = useState<string[]>([])
|
||||
// Order of sub category ids for the selected parent (scope = sub)
|
||||
const [subCategoryOrder, setSubCategoryOrder] = useState<string[]>([])
|
||||
const [dragCategoryId, setDragCategoryId] = useState<string | null>(null)
|
||||
const [parentOrderDirty, setParentOrderDirty] = useState(false)
|
||||
const [subOrderDirty, setSubOrderDirty] = useState(false)
|
||||
const [stepsDirty, setStepsDirty] = useState(false)
|
||||
|
||||
const parentCategories = useMemo(
|
||||
() => categories.filter((c) => !c.parent_id),
|
||||
[categories],
|
||||
)
|
||||
|
||||
const selectedParentCategory = useMemo(
|
||||
() => (scope === "sub" ? categories.find((c) => String(c.id) === selectedParentCategoryId) : undefined),
|
||||
[categories, selectedParentCategoryId, scope],
|
||||
)
|
||||
|
||||
const subCategoriesForParent = useMemo(() => {
|
||||
if (!selectedParentCategoryId) return []
|
||||
return categories.filter((c) => String(c.parent_id) === selectedParentCategoryId)
|
||||
}, [categories, selectedParentCategoryId])
|
||||
|
||||
const selectedSubCategory = useMemo(
|
||||
() => (scope === "sub" ? categories.find((c) => String(c.id) === selectedSubCategoryId) : undefined),
|
||||
[categories, selectedSubCategoryId, scope],
|
||||
)
|
||||
|
||||
// Ordered parent list: use saved order, merge in any new parents from API
|
||||
const orderedParentCategories = useMemo(() => {
|
||||
const byId = new Map(parentCategories.map((c) => [String(c.id), c]))
|
||||
const ordered: CourseCategory[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const id of parentCategoryOrder) {
|
||||
const cat = byId.get(id)
|
||||
if (cat) {
|
||||
ordered.push(cat)
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
for (const c of parentCategories) {
|
||||
if (!seen.has(String(c.id))) ordered.push(c)
|
||||
}
|
||||
return ordered
|
||||
}, [parentCategories, parentCategoryOrder])
|
||||
|
||||
// Ordered sub list for selected parent
|
||||
const orderedSubCategories = useMemo(() => {
|
||||
const byId = new Map(subCategoriesForParent.map((c) => [String(c.id), c]))
|
||||
const ordered: CourseCategory[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const id of subCategoryOrder) {
|
||||
const cat = byId.get(id)
|
||||
if (cat) {
|
||||
ordered.push(cat)
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
for (const c of subCategoriesForParent) {
|
||||
if (!seen.has(String(c.id))) ordered.push(c)
|
||||
}
|
||||
return ordered
|
||||
}, [subCategoriesForParent, subCategoryOrder])
|
||||
|
||||
// Load categories
|
||||
useEffect(() => {
|
||||
const fetchAll = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const catRes = await getCourseCategories()
|
||||
const cats = catRes.data.data.categories ?? []
|
||||
setCategories(cats)
|
||||
} catch (err) {
|
||||
console.error("Failed to load course flows data:", err)
|
||||
setError("Failed to load categories. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAll()
|
||||
}, [])
|
||||
|
||||
// Load parent category order from localStorage (after we have categories)
|
||||
useEffect(() => {
|
||||
if (parentCategories.length === 0) return
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PARENT_ORDER_KEY)
|
||||
if (raw) {
|
||||
const parsed: string[] = JSON.parse(raw)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setParentCategoryOrder(parsed)
|
||||
setParentOrderDirty(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setParentCategoryOrder(parentCategories.map((c) => String(c.id)))
|
||||
setParentOrderDirty(false)
|
||||
}, [parentCategories.length])
|
||||
|
||||
// Load sub category order for selected parent
|
||||
useEffect(() => {
|
||||
if (!selectedParentCategoryId || subCategoriesForParent.length === 0) {
|
||||
setSubCategoryOrder([])
|
||||
return
|
||||
}
|
||||
const key = `${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (raw) {
|
||||
const parsed: string[] = JSON.parse(raw)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setSubCategoryOrder(parsed)
|
||||
setSubOrderDirty(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSubCategoryOrder(subCategoriesForParent.map((c) => String(c.id)))
|
||||
setSubOrderDirty(false)
|
||||
}, [selectedParentCategoryId, subCategoriesForParent.length])
|
||||
|
||||
// Load flow steps for selected sub category only (sub category structure)
|
||||
useEffect(() => {
|
||||
if (scope !== "sub" || !selectedSubCategoryId) {
|
||||
setSteps([])
|
||||
setStepsDirty(false)
|
||||
return
|
||||
}
|
||||
const key = `subcategory_flow_${selectedSubCategoryId}`
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (raw) {
|
||||
const parsed: FlowStep[] = JSON.parse(raw)
|
||||
setSteps(parsed)
|
||||
setStepsDirty(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to default
|
||||
}
|
||||
|
||||
const defaults: FlowStep[] = [
|
||||
{
|
||||
id: `${selectedSubCategoryId}-lesson`,
|
||||
type: "lesson",
|
||||
title: "Core lessons",
|
||||
description: "Main learning content for this sub category.",
|
||||
},
|
||||
{
|
||||
id: `${selectedSubCategoryId}-practice`,
|
||||
type: "practice",
|
||||
title: "Practice sessions",
|
||||
description: "Speaking or practice activities to reinforce learning.",
|
||||
},
|
||||
{
|
||||
id: `${selectedSubCategoryId}-exam`,
|
||||
type: "exam",
|
||||
title: "Exam / Assessment",
|
||||
description: "Formal evaluation of student understanding.",
|
||||
},
|
||||
{
|
||||
id: `${selectedSubCategoryId}-feedback`,
|
||||
type: "feedback",
|
||||
title: "Feedback loop",
|
||||
description: "Collect feedback and share results with learners.",
|
||||
},
|
||||
]
|
||||
setSteps(defaults)
|
||||
setStepsDirty(true)
|
||||
}, [scope, selectedSubCategoryId])
|
||||
|
||||
const handleReorder = (targetId: string) => {
|
||||
if (!dragStepId || dragStepId === targetId) return
|
||||
setSteps((prev) => {
|
||||
const currentIndex = prev.findIndex((s) => s.id === dragStepId)
|
||||
const targetIndex = prev.findIndex((s) => s.id === targetId)
|
||||
if (currentIndex === -1 || targetIndex === -1) return prev
|
||||
const copy = [...prev]
|
||||
const [moved] = copy.splice(currentIndex, 1)
|
||||
copy.splice(targetIndex, 0, moved)
|
||||
return copy
|
||||
})
|
||||
setDragStepId(null)
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
const handleReorderParentCategory = (targetId: string) => {
|
||||
if (!dragCategoryId || dragCategoryId === targetId) return
|
||||
setParentCategoryOrder((prev) => {
|
||||
const currentIndex = prev.indexOf(dragCategoryId)
|
||||
const targetIndex = prev.indexOf(targetId)
|
||||
if (currentIndex === -1 || targetIndex === -1) return prev
|
||||
const copy = [...prev]
|
||||
const [moved] = copy.splice(currentIndex, 1)
|
||||
copy.splice(targetIndex, 0, moved)
|
||||
return copy
|
||||
})
|
||||
setDragCategoryId(null)
|
||||
setParentOrderDirty(true)
|
||||
}
|
||||
|
||||
const handleReorderSubCategory = (targetId: string) => {
|
||||
if (!dragCategoryId || dragCategoryId === targetId) return
|
||||
setSubCategoryOrder((prev) => {
|
||||
const currentIndex = prev.indexOf(dragCategoryId)
|
||||
const targetIndex = prev.indexOf(targetId)
|
||||
if (currentIndex === -1 || targetIndex === -1) return prev
|
||||
const copy = [...prev]
|
||||
const [moved] = copy.splice(currentIndex, 1)
|
||||
copy.splice(targetIndex, 0, moved)
|
||||
return copy
|
||||
})
|
||||
setDragCategoryId(null)
|
||||
setSubOrderDirty(true)
|
||||
}
|
||||
|
||||
const handleSaveParentOrder = () => {
|
||||
if (orderedParentCategories.length === 0 || parentCategoryOrder.length === 0) return
|
||||
window.localStorage.setItem(PARENT_ORDER_KEY, JSON.stringify(parentCategoryOrder))
|
||||
setParentOrderDirty(false)
|
||||
}
|
||||
|
||||
const handleSaveSubOrder = () => {
|
||||
if (!selectedParentCategoryId || subCategoryOrder.length === 0) return
|
||||
window.localStorage.setItem(
|
||||
`${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`,
|
||||
JSON.stringify(subCategoryOrder),
|
||||
)
|
||||
setSubOrderDirty(false)
|
||||
}
|
||||
|
||||
const handleSaveSteps = () => {
|
||||
if (scope !== "sub" || !selectedSubCategoryId) return
|
||||
window.localStorage.setItem(`subcategory_flow_${selectedSubCategoryId}`, JSON.stringify(steps))
|
||||
setStepsDirty(false)
|
||||
}
|
||||
|
||||
const getDefaultDescription = (type: StepType): string => {
|
||||
switch (type) {
|
||||
case "lesson":
|
||||
return "Add the lessons or modules that introduce key concepts."
|
||||
case "practice":
|
||||
return "Connect speaking or practice activities after lessons."
|
||||
case "exam":
|
||||
return "Place exams or quizzes where you want to assess learners."
|
||||
case "feedback":
|
||||
return "Ask for feedback, NPS, or reflection after the exam or final lesson."
|
||||
case "course":
|
||||
return "Link or add an existing course to this flow."
|
||||
case "speaking":
|
||||
return "Speaking or oral practice section for this flow."
|
||||
case "new_course":
|
||||
return "Add a new course within this category."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddStep = (type: StepType) => {
|
||||
const activeId = scope === "sub" ? selectedSubCategoryId : selectedParentCategoryId
|
||||
if (!activeId) return
|
||||
const newStep: FlowStep = {
|
||||
id: `${activeId}-${type}-${Date.now()}`,
|
||||
type,
|
||||
title: STEP_LABELS[type],
|
||||
description: getDefaultDescription(type),
|
||||
}
|
||||
setSteps((prev) => [...prev, newStep])
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
const handleUpdateStep = (id: string, changes: Partial<FlowStep>) => {
|
||||
setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, ...changes } : s)))
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
const handleRemoveStep = (id: string) => {
|
||||
setSteps((prev) => prev.filter((s) => s.id !== id))
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading course flows…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
|
||||
<div className="rounded-full bg-red-100 p-2">
|
||||
<RefreshCw className="h-5 w-5 shrink-0 text-red-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Course Flows</h1>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Arrange parent categories and sub categories into flows, including lessons, practice,
|
||||
exams, and feedback steps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope & selector */}
|
||||
<Card className="shadow-none border border-grayScale-200">
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-3 md:flex-row md:items-center md:gap-4">
|
||||
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScope("parent")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
scope === "parent"
|
||||
? "bg-white text-brand-600 shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Parent categories
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScope("sub")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
scope === "sub"
|
||||
? "bg-white text-brand-600 shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Sub categories
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scope === "sub" && (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Parent category
|
||||
</p>
|
||||
<Select
|
||||
value={selectedParentCategoryId}
|
||||
onChange={(e) => {
|
||||
setSelectedParentCategoryId(e.target.value)
|
||||
setSelectedSubCategoryId("")
|
||||
}}
|
||||
>
|
||||
<option value="">Choose parent…</option>
|
||||
{parentCategories.map((cat) => (
|
||||
<option key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Sub category (structure)
|
||||
</p>
|
||||
<Select
|
||||
value={selectedSubCategoryId}
|
||||
onChange={(e) => setSelectedSubCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">Choose sub category…</option>
|
||||
{subCategoriesForParent.map((child) => (
|
||||
<option key={child.id} value={String(child.id)}>
|
||||
{child.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Parent scope: sequence of parent categories only */}
|
||||
{scope === "parent" && (
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Parent category sequence
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Drag to reorder the sequence in which parent categories appear. No courses or
|
||||
steps—order only.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-[11px]"
|
||||
disabled={!parentOrderDirty || orderedParentCategories.length === 0}
|
||||
onClick={handleSaveParentOrder}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{orderedParentCategories.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 px-4 py-6 text-center text-xs text-grayScale-400">
|
||||
No parent categories. Add categories in Content Management first.
|
||||
</div>
|
||||
) : (
|
||||
orderedParentCategories.map((cat, index) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
draggable
|
||||
onDragStart={() => setDragCategoryId(String(cat.id))}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => handleReorderParentCategory(String(cat.id))}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-3 shadow-sm transition-colors",
|
||||
dragCategoryId === String(cat.id) && "ring-2 ring-brand-300",
|
||||
)}
|
||||
>
|
||||
<button type="button" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-[11px] font-medium text-grayScale-400">#{index + 1}</span>
|
||||
<span className="flex-1 text-sm font-medium text-grayScale-700">{cat.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sub scope: sub category sequence then structure */}
|
||||
{scope === "sub" && selectedParentCategoryId && (
|
||||
<>
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Sub category sequence
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Drag to reorder sub categories under this parent.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-[11px]"
|
||||
disabled={!subOrderDirty || orderedSubCategories.length === 0}
|
||||
onClick={handleSaveSubOrder}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{orderedSubCategories.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 px-4 py-6 text-center text-xs text-grayScale-400">
|
||||
No sub categories under this parent.
|
||||
</div>
|
||||
) : (
|
||||
orderedSubCategories.map((sub, index) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
draggable
|
||||
onDragStart={() => setDragCategoryId(String(sub.id))}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => handleReorderSubCategory(String(sub.id))}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-3 shadow-sm transition-colors",
|
||||
dragCategoryId === String(sub.id) && "ring-2 ring-brand-300",
|
||||
)}
|
||||
>
|
||||
<button type="button" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-[11px] font-medium text-grayScale-400">#{index + 1}</span>
|
||||
<span className="flex-1 text-sm font-medium text-grayScale-700">{sub.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sub category structure: flow steps (only when a sub category is selected) */}
|
||||
{selectedSubCategory && (
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Sub category structure
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Courses, questions, and feedback steps for “{selectedSubCategory.name}”.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-[11px]"
|
||||
disabled={!stepsDirty || steps.length === 0}
|
||||
onClick={handleSaveSteps}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
{steps.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 px-4 py-6 text-center text-xs text-grayScale-400">
|
||||
No steps yet. Use the buttons on the right to add lessons, practice, exams, and
|
||||
feedback loops.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
draggable
|
||||
onDragStart={() => setDragStepId(step.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => handleReorder(step.id)}
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-xl border border-grayScale-100 bg-white p-3.5 shadow-sm transition-colors md:flex-row md:items-start",
|
||||
dragStepId === step.id && "ring-2 ring-brand-300",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 md:flex-col md:items-start">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden h-8 w-8 items-center justify-center rounded-lg text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500 md:flex"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||
STEP_BADGE[step.type],
|
||||
)}
|
||||
>
|
||||
{STEP_LABELS[step.type]}
|
||||
<span className="text-[10px] text-grayScale-400">#{index + 1}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
value={step.title}
|
||||
onChange={(e) => handleUpdateStep(step.id, { title: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={step.description ?? ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateStep(step.id, { description: e.target.value })
|
||||
}
|
||||
placeholder="Optional description for this step"
|
||||
className="h-8 text-xs text-grayScale-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 md:flex-col md:items-end">
|
||||
{step.type !== "feedback" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-[11px]"
|
||||
onClick={() => {
|
||||
const feedbackStep: FlowStep = {
|
||||
id: `${selectedSubCategoryId}-feedback-${Date.now()}`,
|
||||
type: "feedback",
|
||||
title: "Feedback loop",
|
||||
description: "Collect feedback after this step.",
|
||||
}
|
||||
setSteps((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === step.id)
|
||||
if (idx === -1) return prev
|
||||
const copy = [...prev]
|
||||
copy.splice(idx + 1, 0, feedbackStep)
|
||||
return copy
|
||||
})
|
||||
}}
|
||||
>
|
||||
+ Feedback
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-[11px] text-destructive hover:bg-red-50"
|
||||
onClick={() => handleRemoveStep(step.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Palette / What this controls */}
|
||||
<div className="space-y-4">
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold text-grayScale-600">
|
||||
Add steps
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-start gap-2 text-xs"
|
||||
onClick={() => handleAddStep("lesson")}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-sky-500" />
|
||||
Lesson
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-start gap-2 text-xs"
|
||||
onClick={() => handleAddStep("practice")}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Practice section
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-start gap-2 text-xs"
|
||||
onClick={() => handleAddStep("speaking")}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-teal-500" />
|
||||
Speaking section
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-start gap-2 text-xs"
|
||||
onClick={() => handleAddStep("exam")}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
Exam
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-start gap-2 text-xs"
|
||||
onClick={() => handleAddStep("feedback")}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-rose-500" />
|
||||
Feedback
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-start gap-2 text-xs"
|
||||
onClick={() => handleAddStep("course")}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-violet-500" />
|
||||
Course
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 col-span-2 justify-start gap-2 text-xs sm:col-span-2"
|
||||
onClick={() => handleAddStep("new_course")}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-indigo-500" />
|
||||
New course to category
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-grayScale-400">
|
||||
Drag steps in the sequence on the left to change their order. Add feedback loops
|
||||
after exams or any important milestone to keep learners engaged.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/50">
|
||||
<CardContent className="space-y-2 p-4">
|
||||
<p className="text-xs font-semibold text-grayScale-600">How this is used</p>
|
||||
<p className="text-[11px] leading-relaxed text-grayScale-500">
|
||||
This builder helps you map out the ideal learner journey. You can later connect
|
||||
each step to actual lessons, speaking practices, exams, or surveys in your
|
||||
backend. For now, flows are saved locally in your browser.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{scope === "sub" && !selectedParentCategoryId && (
|
||||
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/60">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<p className="text-sm font-semibold text-grayScale-600">
|
||||
Select a parent category to reorder sub categories and edit a sub category’s structure.
|
||||
</p>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-grayScale-400">
|
||||
Use the dropdowns above to choose a parent, then reorder its sub categories and pick a sub category to define courses, questions, and feedback steps.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ import alertSrc from "../../assets/Alert.svg"
|
|||
import { Button } from "../../components/ui/button"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
||||
import type { Course, CourseCategory } from "../../types/course.types"
|
||||
|
||||
|
|
@ -42,8 +41,6 @@ export function CoursesPage() {
|
|||
const [description, setDescription] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [newThumbnailFile, setNewThumbnailFile] = useState<File | null>(null)
|
||||
const [newVideoFile, setNewVideoFile] = useState<File | null>(null)
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
|
||||
|
|
@ -115,8 +112,6 @@ export function CoursesPage() {
|
|||
setTitle("")
|
||||
setDescription("")
|
||||
setSaveError(null)
|
||||
setNewThumbnailFile(null)
|
||||
setNewVideoFile(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
|
|
@ -125,8 +120,6 @@ export function CoursesPage() {
|
|||
setTitle("")
|
||||
setDescription("")
|
||||
setSaveError(null)
|
||||
setNewThumbnailFile(null)
|
||||
setNewVideoFile(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
|
|
@ -249,10 +242,7 @@ export function CoursesPage() {
|
|||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
{/* <div className="rounded-2xl bg-white shadow-sm p-6">
|
||||
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p> */}
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -421,7 +411,7 @@ export function CoursesPage() {
|
|||
{/* Add Course Modal */}
|
||||
{showModal && (
|
||||
<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-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
||||
<div className="mx-4 w-full max-w-md 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">Add New Course</h2>
|
||||
<button
|
||||
|
|
@ -472,29 +462,6 @@ export function CoursesPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-grayScale-600">Thumbnail image</p>
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
onFileSelect={setNewThumbnailFile}
|
||||
label="Upload thumbnail"
|
||||
description="Optional course cover image"
|
||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-grayScale-600">Intro video</p>
|
||||
<FileUpload
|
||||
accept="video/*"
|
||||
onFileSelect={setNewVideoFile}
|
||||
label="Upload intro video"
|
||||
description="Optional overview for this course"
|
||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
||||
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -203,10 +203,7 @@ export function SubCoursesPage() {
|
|||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
{/* <div className="rounded-full bg-white shadow-sm p-4">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p> */}
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
CheckCircle2,
|
||||
XCircle,
|
||||
ArrowUpCircle,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
|
|
@ -202,12 +201,6 @@ export function IssuesPage() {
|
|||
// Status update
|
||||
const [statusUpdating, setStatusUpdating] = useState<number | null>(null);
|
||||
|
||||
// Create issue dialog (admin-created)
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createSubject, setCreateSubject] = useState("");
|
||||
const [createType, setCreateType] = useState<string>("bug");
|
||||
const [createDescription, setCreateDescription] = useState("");
|
||||
|
||||
const fetchIssues = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
@ -352,7 +345,6 @@ export function IssuesPage() {
|
|||
Review and manage user-reported issues across the platform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
|
|
@ -364,14 +356,6 @@ export function IssuesPage() {
|
|||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
|
|
@ -528,7 +512,7 @@ export function IssuesPage() {
|
|||
<TableRow key={issue.id} className="group">
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-3 max-w-[300px]">
|
||||
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-500 group-hover:text-white transition-colors">
|
||||
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
|
||||
<TypeIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -856,90 +840,6 @@ export function IssuesPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Issue Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageCircle className="h-5 w-5 text-brand-500" />
|
||||
<span>Create admin issue</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Log an issue directly from the admin panel so it can be tracked and resolved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Subject
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Short summary of the issue"
|
||||
value={createSubject}
|
||||
onChange={(e) => setCreateSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={createType}
|
||||
onChange={(e) => setCreateType(e.target.value)}
|
||||
className="h-10 w-full rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{ISSUE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{getIssueTypeConfig(t).label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="min-h-[100px] w-full rounded-lg border bg-white px-3 py-2 text-sm text-grayScale-700 placeholder:text-grayScale-400 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Describe what happened, steps to reproduce, and any context that might help."
|
||||
value={createDescription}
|
||||
onChange={(e) => setCreateDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateSubject("");
|
||||
setCreateDescription("");
|
||||
setCreateType("bug");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||
onClick={() => {
|
||||
// Hook to create-issue API here; currently UI-only.
|
||||
if (!createSubject.trim() || !createDescription.trim()) {
|
||||
return;
|
||||
}
|
||||
setCreateOpen(false);
|
||||
setCreateSubject("");
|
||||
setCreateDescription("");
|
||||
setCreateType("bug");
|
||||
}}
|
||||
>
|
||||
Create Issue
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
|
|
|
|||
|
|
@ -19,23 +19,10 @@ import {
|
|||
Mail,
|
||||
CheckCheck,
|
||||
MailX,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { cn } from "../../lib/utils"
|
||||
import {
|
||||
getNotifications,
|
||||
|
|
@ -45,9 +32,7 @@ import {
|
|||
markAllRead,
|
||||
markAllUnread,
|
||||
} from "../../api/notifications.api"
|
||||
import { getTeamMembers } from "../../api/team.api"
|
||||
import type { Notification } from "../../types/notification.types"
|
||||
import type { TeamMember } from "../../types/team.types"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
|
|
@ -241,25 +226,6 @@ export function NotificationsPage() {
|
|||
const [error, setError] = useState(false)
|
||||
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
||||
const [bulkLoading, setBulkLoading] = useState(false)
|
||||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
|
||||
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
|
||||
const [levelFilter, setLevelFilter] = useState<"all" | string>("all")
|
||||
|
||||
const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"])
|
||||
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
|
||||
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
|
||||
const [recipientsLoading, setRecipientsLoading] = useState(false)
|
||||
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
|
||||
const [composeTitle, setComposeTitle] = useState("")
|
||||
const [composeMessage, setComposeMessage] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [composeOpen, setComposeOpen] = useState(false)
|
||||
const [composeImage, setComposeImage] = useState<File | null>(null)
|
||||
|
||||
const fetchData = useCallback(async (currentOffset: number) => {
|
||||
setLoading(true)
|
||||
|
|
@ -334,97 +300,25 @@ export function NotificationsPage() {
|
|||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
||||
|
||||
const filteredNotifications = notifications.filter((n) => {
|
||||
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
|
||||
if (activeStatusTab === "read" && !n.is_read) return false
|
||||
if (activeStatusTab === "unread" && n.is_read) return false
|
||||
if (typeFilter !== "all" && n.type !== typeFilter) return false
|
||||
if (levelFilter !== "all" && n.level !== levelFilter) return false
|
||||
if (searchTerm.trim()) {
|
||||
const q = searchTerm.toLowerCase()
|
||||
const haystack = [
|
||||
n.payload.headline,
|
||||
n.payload.message,
|
||||
formatTypeLabel(n.type),
|
||||
n.delivery_channel,
|
||||
n.level,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
if (!haystack.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const handleOpenDetail = (notification: Notification) => {
|
||||
setSelectedNotification(notification)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const handleComposeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!composeTitle.trim() || !composeMessage.trim()) return
|
||||
if (composeChannels.length === 0) return
|
||||
setSending(true)
|
||||
try {
|
||||
// Hook up to backend send API here when available.
|
||||
// For now, we just reset the form after a short delay for UI feedback.
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
setComposeTitle("")
|
||||
setComposeMessage("")
|
||||
setComposeAudience("all")
|
||||
setComposeChannels(["push"])
|
||||
setSelectedRecipientIds([])
|
||||
setComposeImage(null)
|
||||
setComposeOpen(false)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load users for recipient selection when compose dialog first opens
|
||||
useEffect(() => {
|
||||
if (!composeOpen || teamRecipients.length > 0 || recipientsLoading) return
|
||||
setRecipientsLoading(true)
|
||||
getTeamMembers(1, 50)
|
||||
.then((res) => {
|
||||
setTeamRecipients(res.data.data ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
setTeamRecipients([])
|
||||
})
|
||||
.finally(() => {
|
||||
setRecipientsLoading(false)
|
||||
})
|
||||
}, [composeOpen, teamRecipients.length, recipientsLoading])
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="mb-5">
|
||||
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
|
||||
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>}
|
||||
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
|
||||
{totalCount > 0 && (
|
||||
<Badge variant="secondary">{totalCount}</Badge>
|
||||
)}
|
||||
{globalUnread > 0 && (
|
||||
<Badge variant="default">{globalUnread} unread</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{!loading && !error && (
|
||||
{!loading && !error && notifications.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||
onClick={() => setComposeOpen(true)}
|
||||
>
|
||||
<Megaphone className="mr-2 h-3.5 w-3.5" />
|
||||
New notification
|
||||
</Button>
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
{globalUnread > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -454,58 +348,11 @@ export function NotificationsPage() {
|
|||
Mark all unread
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{!loading && !error && (
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-3">
|
||||
<Card className="shadow-none border border-grayScale-100">
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Total notifications</p>
|
||||
<p className="mt-1 text-xl font-semibold text-grayScale-700">
|
||||
{totalCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-brand-500 text-white">
|
||||
<Bell className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none border border-grayScale-100">
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Unread</p>
|
||||
<p className="mt-1 text-xl font-semibold text-grayScale-700">
|
||||
{globalUnread.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-amber-50 text-amber-600">
|
||||
<BellOff className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none border border-grayScale-100">
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Channels used</p>
|
||||
<p className="mt-1 text-xl font-semibold text-grayScale-700">
|
||||
{Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500">
|
||||
<MailOpen className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
|
|
@ -534,220 +381,26 @@ export function NotificationsPage() {
|
|||
<BellOff className="h-7 w-7 text-grayScale-400" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
|
||||
<span className="text-xs text-grayScale-400">
|
||||
When you receive notifications, they'll appear here.
|
||||
</span>
|
||||
<span className="text-xs text-grayScale-400">When you receive notifications, they'll appear here.</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filters + table */}
|
||||
{/* Notification list */}
|
||||
{!loading && !error && notifications.length > 0 && (
|
||||
<>
|
||||
{/* Status tabs */}
|
||||
<div className="mb-2 border-b border-grayScale-200">
|
||||
<div className="-mb-px flex gap-6">
|
||||
{(["all", "unread", "read"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveStatusTab(tab)}
|
||||
className={cn(
|
||||
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
|
||||
activeStatusTab === tab
|
||||
? "text-brand-600"
|
||||
: "text-grayScale-400 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
{tab === "all" ? "All" : tab === "unread" ? "Unread" : "Read"}
|
||||
{activeStatusTab === tab && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-3 shadow-none">
|
||||
<CardContent className="flex flex-wrap items-center gap-3 p-4">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||
<Input
|
||||
placeholder="Search by title, message, or type…"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-grayScale-500">Channel</span>
|
||||
<Select
|
||||
value={channelFilter}
|
||||
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)}
|
||||
className="h-8 w-[130px] text-xs"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="sms">SMS</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-grayScale-500">Type</span>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="h-8 w-[150px] text-xs"
|
||||
>
|
||||
<option value="all">All types</option>
|
||||
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{formatTypeLabel(t)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-grayScale-500">Level</span>
|
||||
<Select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
className="h-8 w-[130px] text-xs"
|
||||
>
|
||||
<option value="all">All levels</option>
|
||||
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
|
||||
<option key={lvl} value={lvl}>
|
||||
{lvl}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Message</TableHead>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-sm text-grayScale-400">
|
||||
No notifications match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredNotifications.map((n) => {
|
||||
const config = TYPE_CONFIG[n.type] ?? DEFAULT_TYPE_CONFIG
|
||||
const Icon = config.icon
|
||||
const isToggling = togglingIds.has(n.id)
|
||||
return (
|
||||
<TableRow
|
||||
<CardContent className="divide-y-0 p-2">
|
||||
<div className="space-y-1">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!n.is_read && "bg-brand-50/40 hover:bg-brand-50/70",
|
||||
)}
|
||||
onClick={() => handleOpenDetail(n)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-8 w-8 place-items-center rounded-lg text-xs",
|
||||
config.bg,
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
notification={n}
|
||||
onToggleRead={handleToggleRead}
|
||||
toggling={togglingIds.has(n.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-grayScale-600">
|
||||
{formatTypeLabel(n.type)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-xs truncate text-sm font-medium",
|
||||
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
||||
)}
|
||||
>
|
||||
{n.payload.headline}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<p className="max-w-sm truncate text-xs text-grayScale-500">
|
||||
{n.payload.message}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-[10px] capitalize">
|
||||
{n.delivery_channel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={getLevelBadge(n.level)}
|
||||
className="text-[10px] uppercase tracking-wide"
|
||||
>
|
||||
{n.is_read ? "Read" : "Unread"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<span className="text-xs text-grayScale-400">
|
||||
{formatTimestamp(n.timestamp)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div
|
||||
className="flex items-center justify-end gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={isToggling}
|
||||
onClick={() => handleToggleRead(n.id, n.is_read)}
|
||||
title={n.is_read ? "Mark as unread" : "Mark as read"}
|
||||
>
|
||||
{isToggling ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-grayScale-400" />
|
||||
) : n.is_read ? (
|
||||
<Mail className="h-3.5 w-3.5 text-grayScale-400" />
|
||||
) : (
|
||||
<MailOpen className="h-3.5 w-3.5 text-brand-500" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => handleOpenDetail(n)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -782,297 +435,6 @@ export function NotificationsPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Detail dialog */}
|
||||
{selectedNotification && (
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
|
||||
{(() => {
|
||||
const Icon =
|
||||
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
|
||||
return <Icon className="h-4 w-4" />
|
||||
})()}
|
||||
</span>
|
||||
<span className="truncate text-base">
|
||||
{selectedNotification.payload.headline}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sent via {selectedNotification.delivery_channel} ·{" "}
|
||||
{formatTimestamp(selectedNotification.timestamp)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-grayScale-50 p-3">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
{selectedNotification.payload.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-grayScale-400">Type</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{formatTypeLabel(selectedNotification.type)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Level</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{selectedNotification.level}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Channel</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700 capitalize">
|
||||
{selectedNotification.delivery_channel}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Delivery status</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{selectedNotification.delivery_status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Compose dialog */}
|
||||
<Dialog open={composeOpen} onOpenChange={setComposeOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone className="h-5 w-5 text-brand-500" />
|
||||
<span>Create notification</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a one-off push or SMS notification to your users.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleComposeSubmit} className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Channel
|
||||
</p>
|
||||
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setComposeChannels((prev) =>
|
||||
prev.includes("push")
|
||||
? prev.filter((c) => c !== "push")
|
||||
: [...prev, "push"],
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeChannels.includes("push")
|
||||
? "bg-white text-brand-600 shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
Push
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setComposeChannels((prev) =>
|
||||
prev.includes("sms")
|
||||
? prev.filter((c) => c !== "sms")
|
||||
: [...prev, "sms"],
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeChannels.includes("sms")
|
||||
? "bg-white text-brand-600 shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
SMS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||
Audience
|
||||
</p>
|
||||
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setComposeAudience("all")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeAudience === "all"
|
||||
? "bg-white text-brand-600 shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
All users
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setComposeAudience("selected")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||
composeAudience === "selected"
|
||||
? "bg-white text-brand-600 shadow-sm"
|
||||
: "text-grayScale-500 hover:text-grayScale-700",
|
||||
)}
|
||||
>
|
||||
Selected users
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1.2fr)]">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">Title</label>
|
||||
<Input
|
||||
placeholder="Short headline for this notification"
|
||||
value={composeTitle}
|
||||
onChange={(e) => setComposeTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Message
|
||||
</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={
|
||||
composeChannels.includes("sms") && !composeChannels.includes("push")
|
||||
? "Concise SMS body. Keep it clear and under 160 characters where possible."
|
||||
: "Notification body shown inside the app."
|
||||
}
|
||||
value={composeMessage}
|
||||
onChange={(e) => setComposeMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Image (push only)
|
||||
</p>
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
onFileSelect={setComposeImage}
|
||||
label="Upload notification image"
|
||||
description="Shown with push notification where supported"
|
||||
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
<p className="text-[10px] text-grayScale-400">
|
||||
Image will be ignored for SMS-only sends. Connect your push provider to attach it
|
||||
to real notifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{composeAudience === "selected" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-grayScale-500">Recipients</p>
|
||||
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
|
||||
{recipientsLoading && (
|
||||
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading users…
|
||||
</div>
|
||||
)}
|
||||
{!recipientsLoading && teamRecipients.length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-grayScale-400">
|
||||
No users available to select.
|
||||
</div>
|
||||
)}
|
||||
{!recipientsLoading &&
|
||||
teamRecipients.map((member) => (
|
||||
<label
|
||||
key={member.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-grayScale-100"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 rounded border-grayScale-300"
|
||||
checked={selectedRecipientIds.includes(member.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedRecipientIds((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, member.id]
|
||||
: prev.filter((id) => id !== member.id),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{member.first_name} {member.last_name}
|
||||
<span className="ml-1 text-[10px] text-grayScale-400">
|
||||
· {member.email}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
Only the selected users will receive this notification.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
This is a UI-only preview. Hook into your notification API to deliver messages.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setComposeTitle("")
|
||||
setComposeMessage("")
|
||||
setComposeAudience("all")
|
||||
setComposeChannels(["push"])
|
||||
setSelectedRecipientIds([])
|
||||
setComposeImage(null)
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MailOpen className="mr-2 h-3.5 w-3.5" />
|
||||
Send notification
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,259 +0,0 @@
|
|||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ArrowLeft, Briefcase, Mail, Phone, Shield, User, Building2, Calendar } from "lucide-react"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { createTeamMember } from "../../api/team.api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function AddTeamMemberPage() {
|
||||
const navigate = useNavigate()
|
||||
const [firstName, setFirstName] = useState("")
|
||||
const [lastName, setLastName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [role, setRole] = useState("")
|
||||
const [department, setDepartment] = useState("")
|
||||
const [jobTitle, setJobTitle] = useState("")
|
||||
const [employmentType, setEmploymentType] = useState("")
|
||||
const [hireDate, setHireDate] = useState("")
|
||||
const [bio, setBio] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !role || !department || !jobTitle || !employmentType || !hireDate) {
|
||||
toast.error("Missing required fields", {
|
||||
description: "First name, last name, email, phone, role, department, job title, employment type, and hire date are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createTeamMember({
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
email: email.trim(),
|
||||
phone_number: phone.trim(),
|
||||
team_role: role,
|
||||
department,
|
||||
job_title: jobTitle,
|
||||
employment_type: employmentType,
|
||||
hire_date: hireDate,
|
||||
bio: bio.trim() || undefined,
|
||||
})
|
||||
|
||||
toast.success("Team member added", {
|
||||
description: `${firstName} ${lastName} has been created successfully.`,
|
||||
})
|
||||
navigate("/team")
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err?.response?.data?.message ||
|
||||
"Failed to create team member. Please check the details and try again."
|
||||
toast.error("Creation failed", {
|
||||
description: message,
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-lg border border-grayScale-200 bg-white shadow-sm hover:bg-grayScale-50"
|
||||
onClick={() => navigate("/team")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-grayScale-500" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Add Team Member</h1>
|
||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||
Create a new admin/team account with the right role and permissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Team member details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic info */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
First name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Sarah"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
Last name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Ahmed"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
Phone number
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="+251..."
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role & org */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Role
|
||||
</label>
|
||||
<Select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="">Select role</option>
|
||||
<option value="super_admin">Super Admin</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="content_manager">Content Manager</option>
|
||||
<option value="instructor">Instructor</option>
|
||||
<option value="support_agent">Support Agent</option>
|
||||
<option value="finance">Finance</option>
|
||||
<option value="hr">HR</option>
|
||||
<option value="analyst">Analyst</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
Department
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Operations"
|
||||
value={department}
|
||||
onChange={(e) => setDepartment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<Briefcase className="h-3.5 w-3.5" />
|
||||
Job title
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Content Lead"
|
||||
value={jobTitle}
|
||||
onChange={(e) => setJobTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<Briefcase className="h-3.5 w-3.5" />
|
||||
Employment type
|
||||
</label>
|
||||
<Select
|
||||
value={employmentType}
|
||||
onChange={(e) => setEmploymentType(e.target.value)}
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="full_time">Full-time</option>
|
||||
<option value="part_time">Part-time</option>
|
||||
<option value="contractor">Contractor</option>
|
||||
<option value="intern">Intern</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Hire date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={hireDate}
|
||||
onChange={(e) => setHireDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-grayScale-600">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Bio / notes (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder="Short description, responsibilities, or notes about this team member."
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => navigate("/team")}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-brand-500 text-white shadow-sm hover:bg-brand-600 sm:w-auto"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Creating…" : "Create team member"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -145,10 +145,7 @@ export function TeamManagementPage() {
|
|||
Manage user access, roles, and platform permissions.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto"
|
||||
onClick={() => navigate("/team/add")}
|
||||
>
|
||||
<Button className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Team Member
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -405,7 +405,7 @@ export function UserLogPage() {
|
|||
<TableRow key={log.id} className="group">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-500 group-hover:text-white transition-colors">
|
||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
|
||||
<ActionIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,62 +1,14 @@
|
|||
import { useState } from "react"
|
||||
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { createUser } from "../../api/users.api"
|
||||
|
||||
export function RegisterUserPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [firstName, setFirstName] = useState("")
|
||||
const [lastName, setLastName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [phone, setPhone] = useState("")
|
||||
const [role, setRole] = useState("")
|
||||
const [notes, setNotes] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !role) {
|
||||
toast.error("Missing required fields", {
|
||||
description: "Please fill in first name, last name, email, phone, and role.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await createUser({
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
email: email.trim(),
|
||||
phone_number: phone.trim(),
|
||||
role: role.toUpperCase(),
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
|
||||
toast.success("User registered", {
|
||||
description: `${firstName} ${lastName} has been created successfully.`,
|
||||
})
|
||||
|
||||
navigate("/users")
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.response?.data?.message ||
|
||||
"Failed to register user. Please check the details and try again."
|
||||
toast.error("Registration failed", {
|
||||
description: message,
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -70,31 +22,21 @@ export function RegisterUserPage() {
|
|||
</div>
|
||||
|
||||
<Card className="mx-auto max-w-2xl p-6">
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<form className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||
<User className="h-4 w-4" />
|
||||
First Name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Enter first name"
|
||||
required
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
<Input placeholder="Enter first name" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
|
||||
<User className="h-4 w-4" />
|
||||
Last Name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Enter last name"
|
||||
required
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
/>
|
||||
<Input placeholder="Enter last name" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -103,13 +45,7 @@ export function RegisterUserPage() {
|
|||
<Mail className="h-4 w-4" />
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter email address"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input type="email" placeholder="Enter email address" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -117,13 +53,7 @@ export function RegisterUserPage() {
|
|||
<Phone className="h-4 w-4" />
|
||||
Phone
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="Enter phone number"
|
||||
required
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<Input type="tel" placeholder="Enter phone number" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -131,14 +61,10 @@ export function RegisterUserPage() {
|
|||
<Shield className="h-4 w-4" />
|
||||
Role
|
||||
</label>
|
||||
<Select
|
||||
required
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
>
|
||||
<Select required>
|
||||
<option value="">Select role</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="USER">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
|
@ -147,30 +73,15 @@ export function RegisterUserPage() {
|
|||
<FileText className="h-4 w-4" />
|
||||
Notes
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder="Enter any additional notes"
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
<Textarea placeholder="Enter any additional notes" rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => navigate("/users")}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => navigate("/users")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Registering..." : "Register User"}
|
||||
<Button type="submit" className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto">
|
||||
Register User
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -29,32 +29,32 @@ export function UserManagementDashboard() {
|
|||
<Users className="h-6 w-6" />
|
||||
</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-sm font-medium text-grayScale-400">Total Users</p>
|
||||
<p className="text-2xl font-bold text-grayScale-600">1,248</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none bg-brand-50 shadow-sm">
|
||||
<Card className="border-none bg-mint-50 shadow-sm">
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-mint-100 text-mint-600">
|
||||
<UserCheck className="h-6 w-6" />
|
||||
</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-sm font-medium text-grayScale-400">Active Users</p>
|
||||
<p className="text-2xl font-bold text-grayScale-600">1,180</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none bg-brand-50 shadow-sm sm:col-span-2 lg:col-span-1">
|
||||
<Card className="border-none bg-gold-50 shadow-sm sm:col-span-2 lg:col-span-1">
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-gold-100 text-gold-600">
|
||||
<TrendingUp className="h-6 w-6" />
|
||||
</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-sm font-medium text-grayScale-400">New This Month</p>
|
||||
<p className="text-2xl font-bold text-grayScale-600">64</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,9 @@ export interface CourseCategory {
|
|||
id: number
|
||||
name: string
|
||||
is_active: boolean
|
||||
parent_id?: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateCourseCategoryRequest {
|
||||
name: string
|
||||
parent_id?: number | null
|
||||
}
|
||||
|
||||
export interface GetCourseCategoriesResponse {
|
||||
message: string
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -17,19 +17,6 @@ export interface TeamMember {
|
|||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateTeamMemberRequest {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
phone_number: string
|
||||
team_role: string
|
||||
department: string
|
||||
job_title: string
|
||||
employment_type: string
|
||||
hire_date: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
export interface TeamMembersMetadata {
|
||||
total: number
|
||||
total_pages: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user