diff --git a/.env b/.env index 52c1e1c..fca1b5d 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ -VITE_API_BASE_URL=http://api.yimaru.yaltopia.com/ +# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1 +VITE_API_BASE_URL=http://localhost:8080/api/v1 VITE_GOOGLE_CLIENT_ID= diff --git a/package-lock.json b/package-lock.json index c725334..f5f8017 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "yimaru-admin", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource/inter": "^5.2.8", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", @@ -88,6 +91,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -339,6 +343,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2753,6 +2811,7 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2763,6 +2822,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2773,6 +2833,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2828,6 +2889,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -3079,6 +3141,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3317,6 +3380,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3893,6 +3957,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5007,6 +5072,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5054,6 +5120,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5242,6 +5309,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5251,6 +5319,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5270,6 +5339,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5475,7 +5545,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5879,6 +5950,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6046,6 +6118,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6183,6 +6256,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a5aecd5..eba2940 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource/inter": "^5.2.8", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index c831e5a..6aba8db 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -38,6 +38,10 @@ import type { CreateQuestionResponse, CreateVimeoVideoRequest, CreateCourseCategoryRequest, + GetSubCoursePrerequisitesResponse, + AddSubCoursePrerequisiteRequest, + GetLearningPathResponse, + ReorderItem, } from "../types/course.types" export const getCourseCategories = () => @@ -195,3 +199,20 @@ export const deleteQuestionSet = (questionSetId: number) => export const createVimeoVideo = (data: CreateVimeoVideoRequest) => http.post("/course-management/videos/vimeo", data) + +// Sub-course Prerequisite APIs +export const getSubCoursePrerequisites = (subCourseId: number) => + http.get(`/course-management/sub-courses/${subCourseId}/prerequisites`) + +export const addSubCoursePrerequisite = (subCourseId: number, data: AddSubCoursePrerequisiteRequest) => + http.post(`/course-management/sub-courses/${subCourseId}/prerequisites`, data) + +export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId: number) => + http.delete(`/course-management/sub-courses/${subCourseId}/prerequisites/${prerequisiteId}`) + +// Learning Path APIs +export const getLearningPath = (courseId: number) => + http.get(`/course-management/courses/${courseId}/learning-path`) + +export const reorderSubCourses = (courseId: number, items: ReorderItem[]) => + http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items }) diff --git a/src/api/issues.api.ts b/src/api/issues.api.ts index 4032162..19ca938 100644 --- a/src/api/issues.api.ts +++ b/src/api/issues.api.ts @@ -7,6 +7,8 @@ import type { IssueFilters, } from "../types/issue.types"; +import type { CreateIssueRequest, CreateIssueResponse } from "../types/issue.types"; + export const getIssues = (filters?: IssueFilters) => http.get("/issues", { params: filters, @@ -18,6 +20,9 @@ export const getIssuesByUserId = (userId: number) => export const getIssueById = (id: number) => http.get(`/issues/${id}`); +export const createIssue = (payload: CreateIssueRequest) => + http.post("/issues", payload); + export const updateIssueStatus = (id: number, status: string) => http.patch(`/issues/${id}/status`, { status }); diff --git a/src/api/notifications.api.ts b/src/api/notifications.api.ts index 9505b30..bc59789 100644 --- a/src/api/notifications.api.ts +++ b/src/api/notifications.api.ts @@ -20,3 +20,16 @@ export const markAllRead = () => export const markAllUnread = () => http.post("/notifications/mark-all-unread"); + +export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) => + http.post("/notifications/bulk-sms", data); + +export const sendBulkEmail = (formData: FormData) => + http.post("/notifications/bulk-email", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + +export const sendBulkPush = (formData: FormData) => + http.post("/notifications/bulk-push", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); diff --git a/src/api/rbac.api.ts b/src/api/rbac.api.ts new file mode 100644 index 0000000..dc5218e --- /dev/null +++ b/src/api/rbac.api.ts @@ -0,0 +1,25 @@ +import http from "./http" +import type { + GetRolesResponse, + GetRoleDetailResponse, + GetRolesParams, + CreateRoleRequest, + CreateRoleResponse, + SetRolePermissionsRequest, + GetPermissionsResponse, +} from "../types/rbac.types" + +export const getRoles = (params?: GetRolesParams) => + http.get("/rbac/roles", { params }) + +export const getRoleDetail = (roleId: number) => + http.get(`/rbac/roles/${roleId}`) + +export const createRole = (data: CreateRoleRequest) => + http.post("/rbac/roles", data) + +export const setRolePermissions = (roleId: number, data: SetRolePermissionsRequest) => + http.put(`/rbac/roles/${roleId}/permissions`, data) + +export const getAllPermissions = () => + http.get("/rbac/permissions") diff --git a/src/api/users.api.ts b/src/api/users.api.ts index 30936b6..394a516 100644 --- a/src/api/users.api.ts +++ b/src/api/users.api.ts @@ -1,9 +1,18 @@ import http from "./http"; import { type UserProfileResponse, type GetUsersResponse } from "../types/user.types"; -export const getUsers = (page?: number, pageSize?: number) => +export const getUsers = ( + page?: number, + pageSize?: number, + role?: string, + status?: string, + query?: string, +) => http.get("/users", { params: { + role, + status, + query, page, page_size: pageSize, }, diff --git a/src/pages/content-management/CourseCategoryPage.tsx b/src/pages/content-management/CourseCategoryPage.tsx index 90c1857..9c27e6f 100644 --- a/src/pages/content-management/CourseCategoryPage.tsx +++ b/src/pages/content-management/CourseCategoryPage.tsx @@ -291,15 +291,12 @@ export function CourseCategoryPage() { setCreating(true) try { const name = newCategoryName.trim() - const parentPayloadId = parentCategoryId ?? null - const parentRes = await createCourseCategory({ - name: newCategoryName.trim(), - parent_id: parentPayloadId, - }) + const parentRes = await createCourseCategory({ name }) let createdCategoryId: number | null = null try { const data: any = parentRes?.data createdCategoryId = + data?.data?.id ?? data?.data?.category?.id ?? data?.data?.id ?? data?.category?.id ?? @@ -312,10 +309,7 @@ export function CourseCategoryPage() { if (createdCategoryId && pendingSubCategories.length > 0) { await Promise.all( pendingSubCategories.map((subName) => - createCourseCategory({ - name: subName, - parent_id: createdCategoryId, - }), + createCourseCategory({ name: subName }), ), ) } diff --git a/src/pages/content-management/CourseFlowBuilderPage.tsx b/src/pages/content-management/CourseFlowBuilderPage.tsx index 1fbe580..0a87219 100644 --- a/src/pages/content-management/CourseFlowBuilderPage.tsx +++ b/src/pages/content-management/CourseFlowBuilderPage.tsx @@ -1,357 +1,370 @@ import { useEffect, useMemo, useState } from "react" -import { GripVertical, RefreshCw } from "lucide-react" +import { + GripVertical, RefreshCw, Video, BookOpen, ChevronDown, ChevronRight, + X, AlertCircle, Loader2, +} from "lucide-react" +import { + DndContext, closestCenter, KeyboardSensor, PointerSensor, + useSensor, useSensors, type DragEndEvent, +} from "@dnd-kit/core" +import { + arrayMove, SortableContext, sortableKeyboardCoordinates, + verticalListSortingStrategy, useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" -import { Input } from "../../components/ui/input" +import { Badge } from "../../components/ui/badge" import { Select } from "../../components/ui/select" -import { getCourseCategories } from "../../api/courses.api" -import type { CourseCategory } from "../../types/course.types" +import { + getCourseCategories, getCoursesByCategory, + getLearningPath, reorderSubCourses, + addSubCoursePrerequisite, removeSubCoursePrerequisite, +} from "../../api/courses.api" +import type { + CourseCategory, Course, + LearningPathSubCourse, LearningPath, +} from "../../types/course.types" import { cn } from "../../lib/utils" +import { toast } from "sonner" -type StepType = - | "lesson" - | "practice" - | "exam" - | "feedback" - | "course" - | "speaking" - | "new_course" - -type FlowStep = { - id: string - type: StepType - title: string - description?: string +// ── Level badge colours ── +const LEVEL_STYLE: Record = { + BEGINNER: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200", + INTERMEDIATE: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200", + ADVANCED: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200", } -const STEP_LABELS: Record = { - lesson: "Lesson", - practice: "Practice", - exam: "Exam", - feedback: "Feedback loop", - course: "Course", - speaking: "Speaking section", - new_course: "New course (category)", +// ── Sortable sub-course card ── +function SortableSubCourseCard({ + subCourse, + allSubCourses, + onAddPrereq, + onRemovePrereq, + prereqLoading, +}: { + subCourse: LearningPathSubCourse + allSubCourses: LearningPathSubCourse[] + onAddPrereq: (subCourseId: number, prereqId: number) => void + onRemovePrereq: (subCourseId: number, prereqId: number) => void + prereqLoading: number | null +}) { + const { + attributes, listeners, setNodeRef, transform, transition, isDragging, + } = useSortable({ id: subCourse.id }) + + const [expanded, setExpanded] = useState(false) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 50 : undefined, + } + + const availablePrereqs = allSubCourses.filter( + (sc) => + sc.id !== subCourse.id && + !subCourse.prerequisites.some((p) => p.sub_course_id === sc.id), + ) + + return ( +
+ {/* Connector line */} +
+
+
+ +
+ {/* Card header */} +
+ {/* Drag handle */} + + + {/* Order badge */} + + {subCourse.display_order} + + + {/* Title & level */} +
+

+ {subCourse.title} +

+ {subCourse.description && ( +

+ {subCourse.description} +

+ )} +
+ + {/* Level badge */} + + {subCourse.level} + + + {/* Expand toggle */} + +
+ + {/* Stats row */} +
+ + + + + {subCourse.practice_count} practices + + + {subCourse.prerequisite_count} prerequisite{subCourse.prerequisite_count !== 1 ? "s" : ""} + +
+ + {/* Expandable prerequisites section */} + {expanded && ( +
+

+ Prerequisites +

+ + {subCourse.prerequisites.length === 0 && ( +

No prerequisites set.

+ )} + +
+ {subCourse.prerequisites.map((prereq) => ( +
+ {prereq.title} + ({prereq.level}) + +
+ ))} +
+ + {/* Add prerequisite dropdown */} + {availablePrereqs.length > 0 && ( + + )} + + {prereqLoading === subCourse.id && ( +
+ + Updating… +
+ )} +
+ )} +
+
+ ) } -const STEP_BADGE: Record = { - 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_" - +// ── Main page ── export function CourseFlowBuilderPage() { const [categories, setCategories] = useState([]) + const [courses, setCourses] = useState([]) + const [learningPath, setLearningPath] = useState(null) + const [subCourses, setSubCourses] = useState([]) + + const [selectedCategoryId, setSelectedCategoryId] = useState("") + const [selectedCourseId, setSelectedCourseId] = useState("") + const [loading, setLoading] = useState(true) + const [coursesLoading, setCoursesLoading] = useState(false) + const [pathLoading, setPathLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [prereqLoading, setPrereqLoading] = useState(null) const [error, setError] = useState(null) - const [scope, setScope] = useState<"sub" | "parent">("sub") - const [selectedSubCategoryId, setSelectedSubCategoryId] = useState("") - const [selectedParentCategoryId, setSelectedParentCategoryId] = useState("") - const [steps, setSteps] = useState([]) - const [dragStepId, setDragStepId] = useState(null) - // Order of parent category ids (scope = parent) - const [parentCategoryOrder, setParentCategoryOrder] = useState([]) - // Order of sub category ids for the selected parent (scope = sub) - const [subCategoryOrder, setSubCategoryOrder] = useState([]) - const [dragCategoryId, setDragCategoryId] = useState(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 sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) - 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() - 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() - 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 + // Load categories on mount useEffect(() => { - const fetchAll = async () => { + const fetch = 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.") + const res = await getCourseCategories() + setCategories(res.data.data.categories ?? []) + } catch { + setError("Failed to load categories.") } finally { setLoading(false) } } - - fetchAll() + fetch() }, []) - // Load parent category order from localStorage (after we have categories) + // Load courses when category changes 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([]) + if (!selectedCategoryId) { + setCourses([]) + setSelectedCourseId("") 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 - } + const fetch = async () => { + setCoursesLoading(true) + try { + const res = await getCoursesByCategory(Number(selectedCategoryId)) + setCourses(res.data.data.courses ?? []) + } catch { + toast.error("Failed to load courses.") + } finally { + setCoursesLoading(false) } - } catch { - // ignore } - setSubCategoryOrder(subCategoriesForParent.map((c) => String(c.id))) - setSubOrderDirty(false) - }, [selectedParentCategoryId, subCategoriesForParent.length]) + fetch() + }, [selectedCategoryId]) - // Load flow steps for selected sub category only (sub category structure) + // Load learning path when course changes useEffect(() => { - if (scope !== "sub" || !selectedSubCategoryId) { - setSteps([]) - setStepsDirty(false) + if (!selectedCourseId) { + setLearningPath(null) + setSubCourses([]) 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 + const fetch = async () => { + setPathLoading(true) + try { + const res = await getLearningPath(Number(selectedCourseId)) + setLearningPath(res.data.data) + setSubCourses(res.data.data.sub_courses ?? []) + } catch { + toast.error("Failed to load learning path.") + setLearningPath(null) + setSubCourses([]) + } finally { + setPathLoading(false) } + } + fetch() + }, [selectedCourseId]) + + // Drag end → reorder + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = subCourses.findIndex((sc) => sc.id === active.id) + const newIndex = subCourses.findIndex((sc) => sc.id === over.id) + const reordered = arrayMove(subCourses, oldIndex, newIndex) + const updated = reordered.map((sc, i) => ({ ...sc, display_order: i + 1 })) + + setSubCourses(updated) + + setSaving(true) + try { + await reorderSubCourses( + Number(selectedCourseId), + updated.map((sc) => ({ sub_course_id: sc.id, display_order: sc.display_order })), + ) + toast.success("Order saved.") } 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 "" + toast.error("Failed to save order. Reverting…") + setSubCourses(subCourses) + } finally { + setSaving(false) } } - 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), + // Add prerequisite + const handleAddPrereq = async (subCourseId: number, prereqId: number) => { + setPrereqLoading(subCourseId) + try { + await addSubCoursePrerequisite(subCourseId, { prerequisite_sub_course_id: prereqId }) + // Refresh learning path + const res = await getLearningPath(Number(selectedCourseId)) + setSubCourses(res.data.data.sub_courses ?? []) + toast.success("Prerequisite added.") + } catch { + toast.error("Failed to add prerequisite.") + } finally { + setPrereqLoading(null) } - setSteps((prev) => [...prev, newStep]) - setStepsDirty(true) } - const handleUpdateStep = (id: string, changes: Partial) => { - 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) + // Remove prerequisite + const handleRemovePrereq = async (subCourseId: number, prereqId: number) => { + setPrereqLoading(subCourseId) + try { + await removeSubCoursePrerequisite(subCourseId, prereqId) + const res = await getLearningPath(Number(selectedCourseId)) + setSubCourses(res.data.data.sub_courses ?? []) + toast.success("Prerequisite removed.") + } catch { + toast.error("Failed to remove prerequisite.") + } finally { + setPrereqLoading(null) + } } + // ── Loading / error states ── if (loading) { return (
-

Loading course flows…

+

Loading…

) } @@ -360,9 +373,7 @@ export function CourseFlowBuilderPage() { return (
-
- -
+

{error}

@@ -372,431 +383,200 @@ export function CourseFlowBuilderPage() { return (
{/* Header */} -
-
-

Course Flows

-

- Arrange parent categories and sub categories into flows, including lessons, practice, - exams, and feedback steps. -

-
+
+

Learning Path Builder

+

+ Select a course to drag-and-drop reorder its sub-courses and manage prerequisites. +

- {/* Scope & selector */} + {/* Selectors */} - -
-
- - -
+ +
+

+ Category +

+ +
- {scope === "sub" && ( - <> -
-

- Parent category -

- -
-
-

- Sub category (structure) -

- -
- - )} +
+

+ Course +

+
- {/* Parent scope: sequence of parent categories only */} - {scope === "parent" && ( - - -
-
- - Parent category sequence - -

- Drag to reorder the sequence in which parent categories appear. No courses or - steps—order only. -

-
- -
-
- - {orderedParentCategories.length === 0 ? ( -
- No parent categories. Add categories in Content Management first. -
- ) : ( - orderedParentCategories.map((cat, index) => ( -
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", - )} - > - - #{index + 1} - {cat.name} -
- )) - )} + {/* Empty state */} + {!selectedCourseId && ( + + +

+ Select a category and course to begin. +

+

+ Once selected, you can drag to reorder sub-courses and manage prerequisite + dependencies. +

)} - {/* Sub scope: sub category sequence then structure */} - {scope === "sub" && selectedParentCategoryId && ( - <> - - -
-
- - Sub category sequence - -

- Drag to reorder sub categories under this parent. -

-
- -
-
- - {orderedSubCategories.length === 0 ? ( -
- No sub categories under this parent. -
- ) : ( - orderedSubCategories.map((sub, index) => ( -
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", - )} - > - - #{index + 1} - {sub.name} -
- )) - )} -
-
+ {/* Loading path */} + {selectedCourseId && pathLoading && ( +
+ +
+ )} - {/* Sub category structure: flow steps (only when a sub category is selected) */} - {selectedSubCategory && ( -
- - -
-
- - Sub category structure - -

- Courses, questions, and feedback steps for “{selectedSubCategory.name}”. -

-
- + {/* Learning path editor */} + {selectedCourseId && !pathLoading && learningPath && ( +
+ {/* Left – sortable list */} +
+ + +
+
+ + {learningPath.course_title} + +

+ Drag sub-courses to reorder them. Click the arrow to manage prerequisites. +

- - - {steps.length === 0 && ( -
- No steps yet. Use the buttons on the right to add lessons, practice, exams, and - feedback loops. -
- )} - -
- {steps.map((step, index) => ( -
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", - )} - > -
- - - {STEP_LABELS[step.type]} - #{index + 1} +
+ {saving && ( + + + Saving… -
- -
- handleUpdateStep(step.id, { title: e.target.value })} - className="h-8 text-sm" - /> - - handleUpdateStep(step.id, { description: e.target.value }) - } - placeholder="Optional description for this step" - className="h-8 text-xs text-grayScale-500" - /> -
- -
- {step.type !== "feedback" && ( - - )} - -
+ )} + + {subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} +
- ))} -
- - +
+ + + {subCourses.length === 0 ? ( +
+ No sub-courses in this course yet. Add sub-courses from the Content Management page. +
+ ) : ( + + sc.id)} + strategy={verticalListSortingStrategy} + > + {subCourses.map((sc) => ( + + ))} + + + )} +
+ +
- {/* Palette / What this controls */} + {/* Right – info panel */}
- Add steps + Course Details - -
- - - - - - - + + {learningPath.thumbnail && ( + {learningPath.course_title} + )} +
+
+ Category + {learningPath.category_name} +
+
+ Sub-courses + {subCourses.length} +
+
+ Total videos + + {subCourses.reduce((sum, sc) => sum + sc.video_count, 0)} + +
+
+ Total practices + + {subCourses.reduce((sum, sc) => sum + sc.practice_count, 0)} + +
-

- 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. -

+ {learningPath.description && ( +

+ {learningPath.description} +

+ )}
-

How this is used

-

- 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. -

+

How it works

+
    +
  • Drag sub-courses by the grip handle to reorder. Changes save automatically.
  • +
  • Click the arrow on a sub-course to expand and manage its prerequisites.
  • +
  • Prerequisites define which sub-courses a learner must complete first.
  • +
-
- )} - - )} - - {scope === "sub" && !selectedParentCategoryId && ( - - -

- Select a parent category to reorder sub categories and edit a sub category’s structure. -

-

- 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. -

-
-
+
)}
) } - diff --git a/src/pages/content-management/CoursesPage.tsx b/src/pages/content-management/CoursesPage.tsx index a22f03f..f68b337 100644 --- a/src/pages/content-management/CoursesPage.tsx +++ b/src/pages/content-management/CoursesPage.tsx @@ -1,34 +1,24 @@ -import { useEffect, useState, useRef } from "react" +import { useEffect, useState } from "react" import { Link, useParams, useNavigate } from "react-router-dom" -import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, MoreVertical, Edit, AlertCircle } from "lucide-react" +import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle } from "lucide-react" import practiceSrc from "../../assets/Practice.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" -import { Card, CardContent } from "../../components/ui/card" +import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import alertSrc from "../../assets/Alert.svg" import { Button } from "../../components/ui/button" import { Badge } from "../../components/ui/badge" import { Input } from "../../components/ui/input" -import { FileUpload } from "../../components/ui/file-upload" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table" import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api" import type { Course, CourseCategory } from "../../types/course.types" -function CourseThumbnail({ src, alt, gradient }: { src?: string; alt: string; gradient: string }) { - const [imgError, setImgError] = useState(false) - - if (!src || imgError) { - return
- } - - return ( - {alt} setImgError(true)} - /> - ) -} - export function CoursesPage() { const { categoryId } = useParams<{ categoryId: string }>() const navigate = useNavigate() @@ -42,16 +32,10 @@ export function CoursesPage() { const [description, setDescription] = useState("") const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) - const [newThumbnailFile, setNewThumbnailFile] = useState(null) - const [newVideoFile, setNewVideoFile] = useState(null) - const [showDeleteModal, setShowDeleteModal] = useState(false) const [courseToDelete, setCourseToDelete] = useState(null) const [deleting, setDeleting] = useState(false) const [togglingId, setTogglingId] = useState(null) - const [openMenuId, setOpenMenuId] = useState(null) - const menuRef = useRef(null) - const [showEditModal, setShowEditModal] = useState(false) const [courseToEdit, setCourseToEdit] = useState(null) const [editTitle, setEditTitle] = useState("") @@ -60,19 +44,6 @@ export function CoursesPage() { const [updating, setUpdating] = useState(false) const [updateError, setUpdateError] = useState(null) - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setOpenMenuId(null) - } - } - - if (openMenuId !== null) { - document.addEventListener("mousedown", handleClickOutside) - } - return () => document.removeEventListener("mousedown", handleClickOutside) - }, [openMenuId]) - const fetchCourses = async () => { if (!categoryId) return @@ -95,7 +66,7 @@ export function CoursesPage() { getCourseCategories(), ]) - setCourses(coursesRes.data.data.courses) + setCourses(coursesRes.data.data.courses ?? []) const foundCategory = categoriesRes.data.data.categories.find( (c) => c.id === Number(categoryId) ) @@ -115,8 +86,6 @@ export function CoursesPage() { setTitle("") setDescription("") setSaveError(null) - setNewThumbnailFile(null) - setNewVideoFile(null) setShowModal(true) } @@ -125,8 +94,6 @@ export function CoursesPage() { setTitle("") setDescription("") setSaveError(null) - setNewThumbnailFile(null) - setNewVideoFile(null) } const handleSave = async () => { @@ -296,127 +263,123 @@ export function CoursesPage() {
- {/* Course grid or empty state */} - {courses.length === 0 ? ( - - - -

No courses yet

-

No courses found in this category

- -
-
- ) : ( -
- {courses.map((course, index) => { - const gradients = [ - "bg-gradient-to-br from-blue-100 to-blue-200", - "bg-gradient-to-br from-purple-100 to-purple-200", - "bg-gradient-to-br from-green-100 to-green-200", - "bg-gradient-to-br from-yellow-100 to-yellow-200", - ] - return ( - handleCourseClick(course.id)} + {/* Course table or empty state */} + + + + Course Management + + + + {courses.length === 0 ? ( +
+ +

No courses yet

+

+ No courses found in this category. +

+ +
+ ) : ( +
+ + + + + Course + + + Status + + + Actions + + + + + {courses.map((course, index) => ( + handleCourseClick(course.id)} > - - {course.is_active ? "ACTIVE" : "INACTIVE"} - -
e.stopPropagation()}> - - {openMenuId === course.id && ( -
- + -
- + +
- )} -
-
- - {/* Title */} -

{course.title}

-

- {course.description || "No description available"} -

- - {/* Edit button */} - - - - ) - })} - - )} + +
+ ))} +
+
+
+ )} +
+
{/* Add Course Modal */} {showModal && ( @@ -472,29 +435,6 @@ export function CoursesPage() { />
-
-
-

Thumbnail image

- -
-
-

Intro video

- -
-
-
Category: {category?.name}
diff --git a/src/pages/content-management/SubCoursesPage.tsx b/src/pages/content-management/SubCoursesPage.tsx index 56ce098..89553f7 100644 --- a/src/pages/content-management/SubCoursesPage.tsx +++ b/src/pages/content-management/SubCoursesPage.tsx @@ -1,15 +1,15 @@ import { useEffect, useState, useRef } from "react" import { Link, useParams, useNavigate } from "react-router-dom" -import { ArrowLeft, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, AlertCircle, Edit } from "lucide-react" +import { ArrowLeft, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, AlertCircle, Edit, Link2, Plus, Loader2, LayoutGrid, GitBranch, ChevronDown, Lock, ArrowRight } from "lucide-react" import practiceSrc from "../../assets/Practice.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import { Card, CardContent } from "../../components/ui/card" import alertSrc from "../../assets/Alert.svg" import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" -import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse } from "../../api/courses.api" +import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse, getSubCoursePrerequisites, addSubCoursePrerequisite, removeSubCoursePrerequisite } from "../../api/courses.api" import { Input } from "../../components/ui/input" -import type { SubCourse, Course, CourseCategory } from "../../types/course.types" +import type { SubCourse, Course, CourseCategory, SubCoursePrerequisite } from "../../types/course.types" export function SubCoursesPage() { const { categoryId, courseId } = useParams<{ categoryId: string; courseId: string }>() @@ -36,6 +36,22 @@ export function SubCoursesPage() { const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) + // View mode + const [viewMode, setViewMode] = useState<"grid" | "flow">("grid") + + // All prerequisites map: subCourseId -> prerequisites[] + const [allPrereqMap, setAllPrereqMap] = useState>({}) + const [allPrereqLoading, setAllPrereqLoading] = useState(false) + + // Prerequisites state + const [showPrereqModal, setShowPrereqModal] = useState(false) + const [prereqSubCourse, setPrereqSubCourse] = useState(null) + const [prerequisites, setPrerequisites] = useState([]) + const [prereqLoading, setPrereqLoading] = useState(false) + const [prereqAdding, setPrereqAdding] = useState(false) + const [prereqRemoving, setPrereqRemoving] = useState(null) + const [selectedPrereqId, setSelectedPrereqId] = useState(0) + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { @@ -60,6 +76,25 @@ export function SubCoursesPage() { } } + const fetchAllPrerequisites = async (scs: SubCourse[]) => { + if (scs.length === 0) return + setAllPrereqLoading(true) + try { + const results = await Promise.all( + scs.map((sc) => getSubCoursePrerequisites(sc.id).then((res) => ({ id: sc.id, data: res.data.data ?? [] }))) + ) + const map: Record = {} + for (const r of results) { + map[r.id] = r.data + } + setAllPrereqMap(map) + } catch (err) { + console.error("Failed to fetch all prerequisites:", err) + } finally { + setAllPrereqLoading(false) + } + } + useEffect(() => { const fetchData = async () => { if (!courseId || !categoryId) return @@ -93,6 +128,12 @@ export function SubCoursesPage() { fetchData() }, [courseId, categoryId]) + useEffect(() => { + if (subCourses.length > 0) { + fetchAllPrerequisites(subCourses) + } + }, [subCourses]) + const handleToggleStatus = async (subCourse: SubCourse) => { setTogglingId(subCourse.id) try { @@ -199,6 +240,112 @@ export function SubCoursesPage() { navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`) } + const handlePrereqClick = async (subCourse: SubCourse) => { + setPrereqSubCourse(subCourse) + setShowPrereqModal(true) + setPrereqLoading(true) + setSelectedPrereqId(0) + try { + const res = await getSubCoursePrerequisites(subCourse.id) + setPrerequisites(res.data.data ?? []) + } catch (err) { + console.error("Failed to fetch prerequisites:", err) + setPrerequisites([]) + } finally { + setPrereqLoading(false) + } + } + + const handleAddPrerequisite = async () => { + if (!prereqSubCourse || !selectedPrereqId) return + setPrereqAdding(true) + try { + await addSubCoursePrerequisite(prereqSubCourse.id, { + prerequisite_sub_course_id: selectedPrereqId, + }) + const res = await getSubCoursePrerequisites(prereqSubCourse.id) + setPrerequisites(res.data.data ?? []) + setSelectedPrereqId(0) + } catch (err) { + console.error("Failed to add prerequisite:", err) + } finally { + setPrereqAdding(false) + } + } + + const handleRemovePrerequisite = async (prereqId: number) => { + if (!prereqSubCourse) return + setPrereqRemoving(prereqId) + try { + await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId) + const res = await getSubCoursePrerequisites(prereqSubCourse.id) + setPrerequisites(res.data.data ?? []) + } catch (err) { + console.error("Failed to remove prerequisite:", err) + } finally { + setPrereqRemoving(null) + } + } + + // Build flow layers using topological sort + const flowLayers = (() => { + if (subCourses.length === 0) return [] + + // Find sub-courses with no prerequisites (roots) + const hasPrereqs = new Set() + const isPrereqOf = new Map() // prereqId -> [subCourseIds that depend on it] + + for (const sc of subCourses) { + const prereqs = allPrereqMap[sc.id] ?? [] + if (prereqs.length > 0) { + hasPrereqs.add(sc.id) + } + for (const p of prereqs) { + const dependents = isPrereqOf.get(p.prerequisite_sub_course_id) ?? [] + dependents.push(sc.id) + isPrereqOf.set(p.prerequisite_sub_course_id, dependents) + } + } + + // BFS-based layering + const layers: SubCourse[][] = [] + const placed = new Set() + + // Layer 0: no prerequisites + const roots = subCourses.filter((sc) => !hasPrereqs.has(sc.id)) + if (roots.length > 0) { + layers.push(roots) + roots.forEach((sc) => placed.add(sc.id)) + } + + // Subsequent layers: all prereqs already placed + let maxIterations = subCourses.length + while (placed.size < subCourses.length && maxIterations-- > 0) { + const nextLayer = subCourses.filter((sc) => { + if (placed.has(sc.id)) return false + const prereqs = allPrereqMap[sc.id] ?? [] + return prereqs.every((p) => placed.has(p.prerequisite_sub_course_id)) + }) + if (nextLayer.length === 0) { + // Remaining have circular deps or missing prereqs — just add them + const remaining = subCourses.filter((sc) => !placed.has(sc.id)) + if (remaining.length > 0) layers.push(remaining) + break + } + layers.push(nextLayer) + nextLayer.forEach((sc) => placed.add(sc.id)) + } + + return layers + })() + + const availablePrerequisites = subCourses.filter( + (sc) => + prereqSubCourse && + sc.id !== prereqSubCourse.id && + !prerequisites.some((p) => p.prerequisite_sub_course_id === sc.id) + ) + if (loading) { return (
@@ -243,9 +390,37 @@ export function SubCoursesPage() {

{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available

- +
+ {subCourses.length > 0 && ( +
+ + +
+ )} + +
{/* Sub-course grid or empty state */} @@ -320,6 +495,17 @@ export function SubCoursesPage() { {openMenuId === subCourse.id && (
+ +
)} + {/* Prerequisites Modal */} + {showPrereqModal && prereqSubCourse && ( +
+
+
+
+

Prerequisites

+

+ Manage prerequisites for {prereqSubCourse.title} +

+
+ +
+ +
+ {/* Add prerequisite */} + {availablePrerequisites.length > 0 && ( +
+ +
+ + +
+
+ )} + + {/* Current prerequisites list */} + {prereqLoading ? ( +
+ +
+ ) : prerequisites.length === 0 ? ( +
+ +

No prerequisites

+

This sub-course is accessible without completing others first

+
+ ) : ( +
+

+ Current Prerequisites ({prerequisites.length}) +

+ {prerequisites.map((prereq) => ( +
+
+

{prereq.prerequisite_title}

+
+ {prereq.prerequisite_level && ( + + {prereq.prerequisite_level} + + )} + + Order: {prereq.prerequisite_display_order} + +
+
+ +
+ ))} +
+ )} +
+ +
+ +
+
+
+ )} + {/* Edit Sub-course Modal */} {showEditModal && subCourseToEdit && (
diff --git a/src/pages/issues/IssuesPage.tsx b/src/pages/issues/IssuesPage.tsx index 891f63c..76c74f7 100644 --- a/src/pages/issues/IssuesPage.tsx +++ b/src/pages/issues/IssuesPage.tsx @@ -46,6 +46,7 @@ import { getIssueById, updateIssueStatus, deleteIssue, + createIssue, } from "../../api/issues.api"; import type { Issue, IssueFilters } from "../../types/issue.types"; @@ -207,6 +208,9 @@ export function IssuesPage() { const [createSubject, setCreateSubject] = useState(""); const [createType, setCreateType] = useState("bug"); const [createDescription, setCreateDescription] = useState(""); + const [createDevice, setCreateDevice] = useState(""); + const [createBrowser, setCreateBrowser] = useState(""); + const [createSubmitting, setCreateSubmitting] = useState(false); const fetchIssues = useCallback(async () => { setLoading(true); @@ -522,7 +526,6 @@ export function IssuesPage() { const typeConfig = getIssueTypeConfig(issue.issue_type); const statusConfig = getStatusConfig(issue.status); const TypeIcon = typeConfig.icon; - const StatusIcon = statusConfig.icon; return ( @@ -907,6 +910,29 @@ export function IssuesPage() { onChange={(e) => setCreateDescription(e.target.value)} />
+ +
+
+ + setCreateDevice(e.target.value)} + /> +
+
+ + setCreateBrowser(e.target.value)} + /> +
+
@@ -917,24 +943,49 @@ export function IssuesPage() { setCreateSubject(""); setCreateDescription(""); setCreateType("bug"); + setCreateDevice(""); + setCreateBrowser(""); }} + disabled={createSubmitting} > Cancel
diff --git a/src/pages/notifications/NotificationsPage.tsx b/src/pages/notifications/NotificationsPage.tsx index 1afa1f4..59cc635 100644 --- a/src/pages/notifications/NotificationsPage.tsx +++ b/src/pages/notifications/NotificationsPage.tsx @@ -44,10 +44,14 @@ import { markAsUnread, markAllRead, markAllUnread, + sendBulkSms, + sendBulkEmail, + sendBulkPush, } from "../../api/notifications.api" import { getTeamMembers } from "../../api/team.api" import type { Notification } from "../../types/notification.types" import type { TeamMember } from "../../types/team.types" +import { toast } from "sonner" const PAGE_SIZE = 10 @@ -261,6 +265,16 @@ export function NotificationsPage() { const [composeOpen, setComposeOpen] = useState(false) const [composeImage, setComposeImage] = useState(null) + const [bulkOpen, setBulkOpen] = useState(false) + const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms") + const [bulkTitle, setBulkTitle] = useState("") + const [bulkMessage, setBulkMessage] = useState("") + const [bulkRole, setBulkRole] = useState("") + const [bulkUserIds, setBulkUserIds] = useState("") + const [bulkScheduledAt, setBulkScheduledAt] = useState("") + const [bulkFile, setBulkFile] = useState(null) + const [bulkSending, setBulkSending] = useState(false) + const fetchData = useCallback(async (currentOffset: number) => { setLoading(true) setError(false) @@ -418,10 +432,10 @@ export function NotificationsPage() { {notifications.length > 0 && ( <> @@ -1073,6 +1087,243 @@ export function NotificationsPage() { + + {/* Bulk send dialog */} + + + + + + Send notification + + + Send a bulk SMS, email, or push notification to users. + + + +
{ + e.preventDefault() + if (!bulkMessage.trim()) { + toast.error("Message is required") + return + } + const trimmedIds = bulkUserIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + const userIds = trimmedIds.map((id) => Number(id)).filter((id) => !Number.isNaN(id)) + + try { + setBulkSending(true) + + if (bulkChannel === "sms") { + if (userIds.length === 0) { + toast.error("User IDs are required for bulk SMS") + setBulkSending(false) + return + } + await sendBulkSms({ + message: bulkMessage.trim(), + user_ids: userIds, + ...(bulkScheduledAt ? { scheduled_at: bulkScheduledAt } : {}), + }) + } else if (bulkChannel === "email") { + const form = new FormData() + if (!bulkTitle.trim()) { + toast.error("Subject is required for bulk email") + setBulkSending(false) + return + } + form.append("subject", bulkTitle.trim()) + form.append("message", bulkMessage.trim()) + if (bulkRole.trim()) form.append("role", bulkRole.trim()) + if (userIds.length > 0) { + form.append("user_ids", JSON.stringify(userIds)) + } + if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt) + if (bulkFile) form.append("file", bulkFile) + await sendBulkEmail(form) + } else { + const form = new FormData() + if (!bulkTitle.trim()) { + toast.error("Title is required for bulk push") + setBulkSending(false) + return + } + form.append("title", bulkTitle.trim()) + form.append("message", bulkMessage.trim()) + if (bulkRole.trim()) form.append("role", bulkRole.trim()) + if (userIds.length > 0) { + form.append("user_ids", JSON.stringify(userIds)) + } + if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt) + if (bulkFile) form.append("file", bulkFile) + await sendBulkPush(form) + } + + toast.success("Notification scheduled", { + description: bulkScheduledAt + ? "Notification has been scheduled successfully." + : "Notification has been sent successfully.", + }) + + setBulkTitle("") + setBulkMessage("") + setBulkRole("") + setBulkUserIds("") + setBulkScheduledAt("") + setBulkFile(null) + setBulkChannel("sms") + setBulkOpen(false) + } catch (err: any) { + const msg = + err?.response?.data?.message || + "Failed to send notification. Please try again." + toast.error("Failed to send notification", { description: msg }) + } finally { + setBulkSending(false) + } + }} + > +
+
+
+ + +
+
+ + setBulkTitle(e.target.value)} + /> +
+
+ +
+
+ +