From af7890cc8f5d9b84bb9b4d5dd0a2e4af18228103 Mon Sep 17 00:00:00 2001 From: Missez Date: Mon, 26 Jan 2026 09:53:49 +0700 Subject: [PATCH] feat: Add instructor course management pages for creating, listing, and viewing course details. --- frontend_management/pages/admin/index.vue | 2 +- .../pages/instructor/courses/[id]/index.vue | 2 +- .../instructor/courses/[id]/structure.vue | 120 ++++++++++++++--- .../pages/instructor/courses/create.vue | 2 +- .../pages/instructor/courses/index.vue | 4 +- .../pages/instructor/index.vue | 6 +- .../services/instructor.service.ts | 121 +++++++++++++++++- 7 files changed, 229 insertions(+), 28 deletions(-) diff --git a/frontend_management/pages/admin/index.vue b/frontend_management/pages/admin/index.vue index 4d53aac1..96c16868 100644 --- a/frontend_management/pages/admin/index.vue +++ b/frontend_management/pages/admin/index.vue @@ -17,7 +17,7 @@
- ðŸ‘Ļ‍💞 + diff --git a/frontend_management/pages/instructor/courses/[id]/index.vue b/frontend_management/pages/instructor/courses/[id]/index.vue index dc355a93..be7eae69 100644 --- a/frontend_management/pages/instructor/courses/[id]/index.vue +++ b/frontend_management/pages/instructor/courses/[id]/index.vue @@ -230,7 +230,7 @@ const fetchCourse = async () => { const getStatusColor = (status: string) => { const colors: Record = { APPROVED: 'green', - PENDING: 'yellow', + PENDING: 'orange', DRAFT: 'grey', REJECTED: 'red' }; diff --git a/frontend_management/pages/instructor/courses/[id]/structure.vue b/frontend_management/pages/instructor/courses/[id]/structure.vue index 14ce4124..0b24ebbf 100644 --- a/frontend_management/pages/instructor/courses/[id]/structure.vue +++ b/frontend_management/pages/instructor/courses/[id]/structure.vue @@ -108,6 +108,13 @@ v-for="(lesson, lessonIndex) in chapter.lessons" :key="lesson.id" class="py-3" + :class="{ 'opacity-50': draggedLesson?.id === lesson.id, 'bg-primary-50': dragOverLesson?.id === lesson.id }" + draggable="true" + @dragstart="onLessonDragStart(chapter, lesson, $event)" + @dragend="onLessonDragEnd" + @dragover.prevent="onLessonDragOver(chapter, lesson)" + @dragleave="onLessonDragLeave" + @drop.prevent="onLessonDrop(chapter, lesson)" > @@ -120,10 +127,10 @@ - {{ chapterIndex + 1 }}.{{ lessonIndex + 1 }} {{ lesson.title.th }} + {{ lesson.sort_order }}. {{ lesson.title.th }} - {{ getLessonTypeLabel(lesson.type) }} · {{ lesson.duration_minutes }} āļ™āļēāļ—āļĩ + {{ getLessonTypeLabel(lesson.type) }} @@ -218,7 +225,6 @@ v-model="lessonForm.title.th" label="āļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™ (āļ āļēāļĐāļēāđ„āļ—āļĒ) *" outlined - class="mb-4" :rules="[val => !!val || 'āļāļĢāļļāļ“āļēāļāļĢāļ­āļāļŠāļ·āđˆāļ­āļšāļ—āđ€āļĢāļĩāļĒāļ™']" /> - --> + + { } }; +// Lesson Drag and Drop +const draggedLesson = ref(null); +const dragOverLesson = ref(null); +const draggedLessonChapter = ref(null); + +const onLessonDragStart = (chapter: ChapterResponse, lesson: LessonResponse, event: DragEvent) => { + draggedLesson.value = lesson; + draggedLessonChapter.value = chapter; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + } +}; + +const onLessonDragEnd = () => { + draggedLesson.value = null; + dragOverLesson.value = null; + draggedLessonChapter.value = null; +}; + +const onLessonDragOver = (chapter: ChapterResponse, lesson: LessonResponse) => { + // Only allow drag within same chapter + if (draggedLesson.value && draggedLessonChapter.value?.id === chapter.id && draggedLesson.value.id !== lesson.id) { + dragOverLesson.value = lesson; + } +}; + +const onLessonDragLeave = () => { + dragOverLesson.value = null; +}; + +const onLessonDrop = async (chapter: ChapterResponse, targetLesson: LessonResponse) => { + if (!draggedLesson.value || !draggedLessonChapter.value || draggedLessonChapter.value.id !== chapter.id || draggedLesson.value.id === targetLesson.id) { + onLessonDragEnd(); + return; + } + + try { + // Insert at target position - backend will shift other lessons + const targetSortOrder = targetLesson.sort_order; + + await instructorService.reorderLesson(courseId.value, chapter.id, draggedLesson.value.id, targetSortOrder); + + $q.notify({ type: 'positive', message: 'āđ€āļĢāļĩāļĒāļ‡āļĨāļģāļ”āļąāļšāļšāļ—āđ€āļĢāļĩāļĒāļ™āļŠāļģāđ€āļĢāđ‡āļˆ', position: 'top' }); + fetchChapters(); + } catch (error) { + $q.notify({ type: 'negative', message: 'āđ„āļĄāđˆāļŠāļēāļĄāļēāļĢāļ–āđ€āļĢāļĩāļĒāļ‡āļĨāļģāļ”āļąāļšāđ„āļ”āđ‰', position: 'top' }); + } finally { + onLessonDragEnd(); + } +}; + // Forms const chapterForm = ref({ title: { th: '', en: '' }, @@ -392,14 +461,12 @@ const chapterForm = ref({ const lessonForm = ref({ title: { th: '', en: '' }, content: { th: '', en: '' }, - type: 'VIDEO' as 'VIDEO' | 'QUIZ' | 'DOCUMENT', - duration_minutes: 10 + type: 'VIDEO' as 'VIDEO' | 'QUIZ' }); const lessonTypeOptions = [ { label: 'āļ§āļīāļ”āļĩāđ‚āļ­', value: 'VIDEO' }, { label: 'āđāļšāļšāļ—āļ”āļŠāļ­āļš', value: 'QUIZ' }, - { label: 'āđ€āļ­āļāļŠāļēāļĢ', value: 'DOCUMENT' } ]; // Methods @@ -513,16 +580,14 @@ const openLessonDialog = (chapter: ChapterResponse, lesson?: LessonResponse) => lessonForm.value = { title: { ...lesson.title }, content: lesson.content ? { ...lesson.content } : { th: '', en: '' }, - type: lesson.type, - duration_minutes: lesson.duration_minutes + type: lesson.type as 'VIDEO' | 'QUIZ' }; } else { editingLesson.value = null; lessonForm.value = { title: { th: '', en: '' }, content: { th: '', en: '' }, - type: 'VIDEO', - duration_minutes: 10 + type: 'VIDEO' }; } lessonDialog.value = true; @@ -531,11 +596,32 @@ const openLessonDialog = (chapter: ChapterResponse, lesson?: LessonResponse) => const saveLesson = async () => { saving.value = true; try { + if (!selectedChapter.value) return; + if (editingLesson.value) { - // TODO: Call updateLesson API + // Update - don't send type + const updateData = { + title: lessonForm.value.title, + content: lessonForm.value.content + }; + await instructorService.updateLesson( + courseId.value, + selectedChapter.value.id, + editingLesson.value.id, + updateData + ); $q.notify({ type: 'positive', message: 'āđāļāđ‰āđ„āļ‚āļšāļ—āđ€āļĢāļĩāļĒāļ™āļŠāļģāđ€āļĢāđ‡āļˆ', position: 'top' }); } else { - // TODO: Call createLesson API + // Create - include type and sort_order + const createData = { + ...lessonForm.value, + sort_order: (selectedChapter.value.lessons?.length || 0) + 1 + }; + await instructorService.createLesson( + courseId.value, + selectedChapter.value.id, + createData + ); $q.notify({ type: 'positive', message: 'āđ€āļžāļīāđˆāļĄāļšāļ—āđ€āļĢāļĩāļĒāļ™āļŠāļģāđ€āļĢāđ‡āļˆ', position: 'top' }); } lessonDialog.value = false; @@ -555,7 +641,7 @@ const confirmDeleteLesson = (chapter: ChapterResponse, lesson: LessonResponse) = persistent: true }).onOk(async () => { try { - // TODO: Call deleteLesson API + await instructorService.deleteLesson(courseId.value, chapter.id, lesson.id); $q.notify({ type: 'positive', message: 'āļĨāļšāļšāļ—āđ€āļĢāļĩāļĒāļ™āļŠāļģāđ€āļĢāđ‡āļˆ', position: 'top' }); fetchChapters(); } catch (error) { diff --git a/frontend_management/pages/instructor/courses/create.vue b/frontend_management/pages/instructor/courses/create.vue index a4e0eafe..9eaa7227 100644 --- a/frontend_management/pages/instructor/courses/create.vue +++ b/frontend_management/pages/instructor/courses/create.vue @@ -168,7 +168,7 @@ const categories = ref([]); // Form const form = ref({ - category_id: 0, + category_id: 1, title: { th: '', en: '' }, slug: '', description: { th: '', en: '' }, diff --git a/frontend_management/pages/instructor/courses/index.vue b/frontend_management/pages/instructor/courses/index.vue index 2e689f77..c7cb5b18 100644 --- a/frontend_management/pages/instructor/courses/index.vue +++ b/frontend_management/pages/instructor/courses/index.vue @@ -129,7 +129,7 @@ > āļ”āļđāļĢāļēāļĒāļĨāļ°āđ€āļ­āļĩāļĒāļ” - āđāļāđ‰āđ„āļ‚ - + --> diff --git a/frontend_management/pages/instructor/index.vue b/frontend_management/pages/instructor/index.vue index 380387d5..8947110b 100644 --- a/frontend_management/pages/instructor/index.vue +++ b/frontend_management/pages/instructor/index.vue @@ -17,7 +17,7 @@
- ðŸ‘Ļ‍ðŸŦ + @@ -83,7 +83,7 @@ -

📊 āļŠāļ–āļīāļ•āļīāļœāļđāđ‰āļŠāļĄāļąāļ„āļĢāļĢāļ§āļĄ (āļĢāļēāļĒāđ€āļ”āļ·āļ­āļ™)

+

āļŠāļ–āļīāļ•āļīāļœāļđāđ‰āļŠāļĄāļąāļ„āļĢāļĢāļ§āļĄ (āļĢāļēāļĒāđ€āļ”āļ·āļ­āļ™)

[āļāļĢāļēāļŸāđāļŠāļ”āļ‡āļŠāļ–āļīāļ•āļīāļœāļđāđ‰āļŠāļĄāļąāļ„āļĢāļĢāļ§āļĄ (āļĢāļēāļĒāđ€āļ”āļ·āļ­āļ™)]
@@ -94,7 +94,7 @@
-

📚 āļŦāļĨāļąāļāļŠāļđāļ•āļĢāļĨāđˆāļēāļŠāļļāļ”

+

āļŦāļĨāļąāļāļŠāļđāļ•āļĢ

( - `/api/instructors/courses/${courseId}/chapters` + // Get chapters from course detail endpoint + const response = await authRequest<{ code: number; data: { chapters: ChapterResponse[] } }>( + `/api/instructors/courses/${courseId}` ); - return response.data; + return response.data.chapters || []; }, async createChapter(courseId: number, data: CreateChapterRequest): Promise { @@ -333,6 +334,92 @@ export const instructorService = { `/api/instructors/courses/${courseId}/chapters/${chapterId}/reorder`, { method: 'PUT', body: { sort_order: sortOrder } } ); + }, + + // Lesson CRUD + async getLesson(courseId: number, chapterId: number, lessonId: number): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return MOCK_COURSE_DETAIL.chapters[0].lessons[0] as LessonDetailResponse; + } + + const response = await authRequest<{ code: number; data: LessonDetailResponse }>( + `/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}` + ); + return response.data; + }, + + async createLesson(courseId: number, chapterId: number, data: CreateLessonRequest): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + ...MOCK_COURSE_DETAIL.chapters[0].lessons[0], + id: Date.now(), + ...data + }; + } + + const response = await authRequest<{ code: number; data: LessonResponse }>( + `/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons`, + { method: 'POST', body: data } + ); + return response.data; + }, + + async updateLesson(courseId: number, chapterId: number, lessonId: number, data: UpdateLessonRequest): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + ...MOCK_COURSE_DETAIL.chapters[0].lessons[0], + id: lessonId, + ...data + }; + } + + const response = await authRequest<{ code: number; data: LessonResponse }>( + `/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}`, + { method: 'PUT', body: data } + ); + return response.data; + }, + + async deleteLesson(courseId: number, chapterId: number, lessonId: number): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 500)); + return; + } + + await authRequest( + `/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}`, + { method: 'DELETE' } + ); + }, + + async reorderLesson(courseId: number, chapterId: number, lessonId: number, sortOrder: number): Promise { + const config = useRuntimeConfig(); + const useMockData = config.public.useMockData as boolean; + + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 300)); + return; + } + + await authRequest( + `/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/reorder`, + { method: 'PUT', body: { lesson_id: lessonId, sort_order: sortOrder } } + ); } }; @@ -407,6 +494,34 @@ export interface CreateChapterRequest { is_published?: boolean; } +export interface CreateLessonRequest { + title: { th: string; en: string }; + content?: { th: string; en: string } | null; + type: 'VIDEO' | 'QUIZ'; + sort_order?: number; +} + +export interface UpdateLessonRequest { + title: { th: string; en: string }; + content?: { th: string; en: string } | null; +} + +export interface AttachmentResponse { + id: number; + lesson_id: number; + file_name: string; + file_path: string; + file_size: number; + mime_type: string; + description: { th: string; en: string }; + sort_order: number; + created_at: string; +} + +export interface LessonDetailResponse extends LessonResponse { + attachments: AttachmentResponse[]; +} + // Mock course detail const MOCK_COURSE_DETAIL: CourseDetailResponse = { ...MOCK_COURSES[0],