feat: Add instructor course management pages for creating, listing, and viewing course details.

This commit is contained in:
Missez 2026-01-26 09:53:49 +07:00
parent 69eb60f901
commit af7890cc8f
7 changed files with 229 additions and 28 deletions

View file

@ -17,7 +17,7 @@
<div <div
class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition" class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition"
> >
👨💼 <q-icon name="person" />
<q-menu> <q-menu>
<q-list style="min-width: 200px"> <q-list style="min-width: 200px">
<!-- User Info Header --> <!-- User Info Header -->

View file

@ -230,7 +230,7 @@ const fetchCourse = async () => {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
APPROVED: 'green', APPROVED: 'green',
PENDING: 'yellow', PENDING: 'orange',
DRAFT: 'grey', DRAFT: 'grey',
REJECTED: 'red' REJECTED: 'red'
}; };

View file

@ -108,6 +108,13 @@
v-for="(lesson, lessonIndex) in chapter.lessons" v-for="(lesson, lessonIndex) in chapter.lessons"
:key="lesson.id" :key="lesson.id"
class="py-3" 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)"
> >
<q-item-section avatar> <q-item-section avatar>
<q-icon name="drag_indicator" class="cursor-move text-gray-300" /> <q-icon name="drag_indicator" class="cursor-move text-gray-300" />
@ -120,10 +127,10 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
{{ chapterIndex + 1 }}.{{ lessonIndex + 1 }} {{ lesson.title.th }} {{ lesson.sort_order }}. {{ lesson.title.th }}
</q-item-label> </q-item-label>
<q-item-label caption> <q-item-label caption>
{{ getLessonTypeLabel(lesson.type) }} · {{ lesson.duration_minutes }} นาท {{ getLessonTypeLabel(lesson.type) }}
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
@ -218,7 +225,6 @@
v-model="lessonForm.title.th" v-model="lessonForm.title.th"
label="ชื่อบทเรียน (ภาษาไทย) *" label="ชื่อบทเรียน (ภาษาไทย) *"
outlined outlined
class="mb-4"
:rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']" :rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']"
/> />
<q-input <q-input
@ -229,6 +235,7 @@
/> />
<q-select <q-select
v-if="!editingLesson"
v-model="lessonForm.type" v-model="lessonForm.type"
:options="lessonTypeOptions" :options="lessonTypeOptions"
label="ประเภท *" label="ประเภท *"
@ -238,18 +245,29 @@
class="mb-4" class="mb-4"
/> />
<q-input <!-- <q-input
v-model.number="lessonForm.duration_minutes" v-model.number="lessonForm.duration_minutes"
label="ระยะเวลา (นาที)" label="ระยะเวลา (นาที)"
type="number" type="number"
outlined outlined
class="mb-4" class="mb-4"
/> -->
<q-input
v-if="lessonForm.type"
v-model="lessonForm.content.th"
label="เนื้อหา (ภาษาไทย)"
type="textarea"
outlined
autogrow
rows="3"
class="mb-4"
/> />
<q-input <q-input
v-if="lessonForm.type !== 'QUIZ'" v-if="lessonForm.type"
v-model="lessonForm.content.th" v-model="lessonForm.content.en"
label="เนื้อหา (ภาษาไทย)" label="เนื้อหา (English)"
type="textarea" type="textarea"
outlined outlined
autogrow autogrow
@ -383,6 +401,57 @@ const onDrop = async (targetChapter: ChapterResponse) => {
} }
}; };
// Lesson Drag and Drop
const draggedLesson = ref<LessonResponse | null>(null);
const dragOverLesson = ref<LessonResponse | null>(null);
const draggedLessonChapter = ref<ChapterResponse | null>(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 // Forms
const chapterForm = ref({ const chapterForm = ref({
title: { th: '', en: '' }, title: { th: '', en: '' },
@ -392,14 +461,12 @@ const chapterForm = ref({
const lessonForm = ref({ const lessonForm = ref({
title: { th: '', en: '' }, title: { th: '', en: '' },
content: { th: '', en: '' }, content: { th: '', en: '' },
type: 'VIDEO' as 'VIDEO' | 'QUIZ' | 'DOCUMENT', type: 'VIDEO' as 'VIDEO' | 'QUIZ'
duration_minutes: 10
}); });
const lessonTypeOptions = [ const lessonTypeOptions = [
{ label: 'วิดีโอ', value: 'VIDEO' }, { label: 'วิดีโอ', value: 'VIDEO' },
{ label: 'แบบทดสอบ', value: 'QUIZ' }, { label: 'แบบทดสอบ', value: 'QUIZ' },
{ label: 'เอกสาร', value: 'DOCUMENT' }
]; ];
// Methods // Methods
@ -513,16 +580,14 @@ const openLessonDialog = (chapter: ChapterResponse, lesson?: LessonResponse) =>
lessonForm.value = { lessonForm.value = {
title: { ...lesson.title }, title: { ...lesson.title },
content: lesson.content ? { ...lesson.content } : { th: '', en: '' }, content: lesson.content ? { ...lesson.content } : { th: '', en: '' },
type: lesson.type, type: lesson.type as 'VIDEO' | 'QUIZ'
duration_minutes: lesson.duration_minutes
}; };
} else { } else {
editingLesson.value = null; editingLesson.value = null;
lessonForm.value = { lessonForm.value = {
title: { th: '', en: '' }, title: { th: '', en: '' },
content: { th: '', en: '' }, content: { th: '', en: '' },
type: 'VIDEO', type: 'VIDEO'
duration_minutes: 10
}; };
} }
lessonDialog.value = true; lessonDialog.value = true;
@ -531,11 +596,32 @@ const openLessonDialog = (chapter: ChapterResponse, lesson?: LessonResponse) =>
const saveLesson = async () => { const saveLesson = async () => {
saving.value = true; saving.value = true;
try { try {
if (!selectedChapter.value) return;
if (editingLesson.value) { 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' }); $q.notify({ type: 'positive', message: 'แก้ไขบทเรียนสำเร็จ', position: 'top' });
} else { } 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' }); $q.notify({ type: 'positive', message: 'เพิ่มบทเรียนสำเร็จ', position: 'top' });
} }
lessonDialog.value = false; lessonDialog.value = false;
@ -555,7 +641,7 @@ const confirmDeleteLesson = (chapter: ChapterResponse, lesson: LessonResponse) =
persistent: true persistent: true
}).onOk(async () => { }).onOk(async () => {
try { try {
// TODO: Call deleteLesson API await instructorService.deleteLesson(courseId.value, chapter.id, lesson.id);
$q.notify({ type: 'positive', message: 'ลบบทเรียนสำเร็จ', position: 'top' }); $q.notify({ type: 'positive', message: 'ลบบทเรียนสำเร็จ', position: 'top' });
fetchChapters(); fetchChapters();
} catch (error) { } catch (error) {

View file

@ -168,7 +168,7 @@ const categories = ref<CategoryResponse[]>([]);
// Form // Form
const form = ref<CreateCourseRequest>({ const form = ref<CreateCourseRequest>({
category_id: 0, category_id: 1,
title: { th: '', en: '' }, title: { th: '', en: '' },
slug: '', slug: '',
description: { th: '', en: '' }, description: { th: '', en: '' },

View file

@ -129,7 +129,7 @@
> >
<q-tooltip>รายละเอยด</q-tooltip> <q-tooltip>รายละเอยด</q-tooltip>
</q-btn> </q-btn>
<q-btn <!-- <q-btn
flat flat
dense dense
icon="edit" icon="edit"
@ -137,7 +137,7 @@
@click="navigateTo(`/instructor/courses/${course.id}/edit`)" @click="navigateTo(`/instructor/courses/${course.id}/edit`)"
> >
<q-tooltip>แกไข</q-tooltip> <q-tooltip>แกไข</q-tooltip>
</q-btn> </q-btn> -->
<q-space /> <q-space />
<q-btn flat round dense icon="more_vert"> <q-btn flat round dense icon="more_vert">
<q-menu> <q-menu>

View file

@ -17,7 +17,7 @@
<div <div
class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition" class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition"
> >
👨🏫 <q-icon name="person" />
<q-menu> <q-menu>
<q-list style="min-width: 200px"> <q-list style="min-width: 200px">
<!-- User Info Header --> <!-- User Info Header -->
@ -83,7 +83,7 @@
<!-- Chart Placeholder --> <!-- Chart Placeholder -->
<q-card> <q-card>
<q-card-section> <q-card-section>
<h3 class="text-xl font-semibold mb-4">📊 สถสมครรวม (รายเดอน)</h3> <h3 class="text-xl font-semibold mb-4"> สถสมครรวม (รายเดอน)</h3>
<div class="bg-gray-100 rounded-lg p-12 text-center text-gray-500"> <div class="bg-gray-100 rounded-lg p-12 text-center text-gray-500">
[กราฟแสดงสถสมครรวม (รายเดอน)] [กราฟแสดงสถสมครรวม (รายเดอน)]
</div> </div>
@ -94,7 +94,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold">📚 หลกสตราส</h3> <h3 class="text-xl font-semibold"> หลกสตร</h3>
<q-btn <q-btn
flat flat
color="primary" color="primary"

View file

@ -258,10 +258,11 @@ export const instructorService = {
return MOCK_COURSE_DETAIL.chapters; return MOCK_COURSE_DETAIL.chapters;
} }
const response = await authRequest<{ code: number; data: ChapterResponse[]; total: number }>( // Get chapters from course detail endpoint
`/api/instructors/courses/${courseId}/chapters` 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<ChapterResponse> { async createChapter(courseId: number, data: CreateChapterRequest): Promise<ChapterResponse> {
@ -333,6 +334,92 @@ export const instructorService = {
`/api/instructors/courses/${courseId}/chapters/${chapterId}/reorder`, `/api/instructors/courses/${courseId}/chapters/${chapterId}/reorder`,
{ method: 'PUT', body: { sort_order: sortOrder } } { method: 'PUT', body: { sort_order: sortOrder } }
); );
},
// Lesson CRUD
async getLesson(courseId: number, chapterId: number, lessonId: number): Promise<LessonDetailResponse> {
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<LessonResponse> {
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<LessonResponse> {
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<void> {
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<void> {
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; 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 // Mock course detail
const MOCK_COURSE_DETAIL: CourseDetailResponse = { const MOCK_COURSE_DETAIL: CourseDetailResponse = {
...MOCK_COURSES[0], ...MOCK_COURSES[0],