feat: Introduce admin pages for pending course review and course details, and instructor pages for course management and lesson quizzes.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 33s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 2s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 33s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 2s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
This commit is contained in:
parent
ff91df2bd6
commit
941b195813
6 changed files with 388 additions and 27 deletions
228
frontend_management/components/course/LessonPreviewDialog.vue
Normal file
228
frontend_management/components/course/LessonPreviewDialog.vue
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<template>
|
||||
<q-dialog v-model="isOpen" maximizable>
|
||||
<q-card class="column" style="width: 900px; max-width: 95vw; height: 90vh">
|
||||
<q-card-section class="row items-center q-pb-none bg-gray-50 border-b">
|
||||
<div class="text-h6 font-bold text-gray-900 flex items-center gap-2">
|
||||
<q-icon :name="getIcon(lesson.type)" color="primary" />
|
||||
{{ lesson.title.th }}
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense v-close-popup />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="col q-pa-none scroll relative-position">
|
||||
<div v-if="loading" class="absolute-center">
|
||||
<q-spinner-dots size="40px" color="primary" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="lessonDetail" class="p-6 space-y-6">
|
||||
<!-- Video Player -->
|
||||
<div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden">
|
||||
<video
|
||||
:src="lessonDetail.video_url"
|
||||
controls
|
||||
class="w-full h-full object-contain"
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- Content (Document/Text) -->
|
||||
<div v-if="lessonDetail.content" class="prose max-w-none">
|
||||
<div v-html="lessonDetail.content.th"></div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div v-if="lessonDetail.attachments && lessonDetail.attachments.length > 0">
|
||||
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<q-icon name="attach_file" />
|
||||
เอกสารแนบ
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<a
|
||||
v-for="file in lessonDetail.attachments"
|
||||
:key="file.id"
|
||||
:href="file.file_path"
|
||||
target="_blank"
|
||||
class="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<q-icon name="description" color="primary" size="24px" />
|
||||
<div class="overflow-hidden">
|
||||
<div class="font-medium truncate">{{ file.file_name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ formatFileSize(file.file_size) }}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz Info -->
|
||||
<div v-if="lesson.type === 'QUIZ'">
|
||||
<div v-if="lessonDetail.quiz" class="bg-blue-50 p-6 rounded-lg border border-blue-100">
|
||||
<h3 class="text-lg font-bold text-blue-900 mb-2">{{ lessonDetail.quiz.title?.th || 'แบบทดสอบ' }}</h3>
|
||||
<p class="text-blue-800 mb-4">{{ lessonDetail.quiz.description?.th || '-' }}</p>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div class="bg-white p-3 rounded border border-blue-100">
|
||||
<div class="text-gray-500 mb-1">เกณฑ์ผ่าน</div>
|
||||
<div class="font-semibold">{{ lessonDetail.quiz.passing_score }}%</div>
|
||||
</div>
|
||||
<div class="bg-white p-3 rounded border border-blue-100">
|
||||
<div class="text-gray-500 mb-1">เวลาทำข้อสอบ</div>
|
||||
<div class="font-semibold">{{ lessonDetail.quiz.time_limit ? lessonDetail.quiz.time_limit + ' นาที' : '-' }}</div>
|
||||
</div>
|
||||
<div class="bg-white p-3 rounded border border-blue-100">
|
||||
<div class="text-gray-500 mb-1">จำนวนข้อ</div>
|
||||
<div class="font-semibold">{{ lessonDetail.quiz.questions?.length || 0 }} ข้อ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions List -->
|
||||
<div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6">
|
||||
<!-- ... (questions rendering code unchanged) ... -->
|
||||
<div v-for="(question, index) in lessonDetail.quiz.questions" :key="question.id" class="bg-white p-4 rounded-lg border border-blue-100">
|
||||
<div class="flex gap-3">
|
||||
<span class="font-bold text-blue-600 text-lg">{{ index + 1 }}.</span>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 text-lg mb-2">{{ question.question?.th || 'คำถาม' }}</p>
|
||||
|
||||
<!-- Choices -->
|
||||
<div class="space-y-2 mt-3">
|
||||
<div
|
||||
v-for="choice in question.choices"
|
||||
:key="choice.id"
|
||||
class="p-3 rounded border flex items-center gap-3"
|
||||
:class="choice.is_correct ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0"
|
||||
:class="choice.is_correct ? 'border-green-500' : 'border-gray-400'"
|
||||
>
|
||||
<div v-if="choice.is_correct" class="w-2.5 h-2.5 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div :class="choice.is_correct ? 'text-green-700 font-medium' : 'text-gray-700'">
|
||||
<div>{{ choice.text?.th || '-' }}</div>
|
||||
<div v-if="choice.text?.en" class="text-xs opacity-75">{{ choice.text.en }}</div>
|
||||
</div>
|
||||
<q-icon v-if="choice.is_correct" name="check_circle" color="green" class="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation -->
|
||||
<div v-if="question.explanation?.th" class="mt-4 bg-yellow-50 p-3 rounded text-sm text-yellow-800 border border-yellow-100">
|
||||
<div class="font-semibold mb-1 flex items-center gap-1">
|
||||
<q-icon name="lightbulb" />
|
||||
คำอธิบาย:
|
||||
</div>
|
||||
{{ question.explanation.th }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback for missing Quiz Data -->
|
||||
<div v-else class="flex flex-col items-center justify-center p-10 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
|
||||
<q-icon name="assignment_late" size="48px" class="mb-2" />
|
||||
<p>ไม่พบข้อมูลแบบทดสอบ หรือแบบทดสอบยังไม่สมบูรณ์</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<q-icon name="error_outline" size="48px" class="mb-2" />
|
||||
<p>ไม่พบข้อมูลบทเรียน</p>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { instructorService, type LessonResponse, type LessonDetailResponse } from '~/services/instructor.service';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
lesson: LessonResponse;
|
||||
courseId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const $q = useQuasar();
|
||||
|
||||
const loading = ref(false);
|
||||
const lessonDetail = ref<LessonDetailResponse | null>(null);
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
VIDEO: 'play_circle',
|
||||
DOCUMENT: 'description',
|
||||
QUIZ: 'quiz'
|
||||
};
|
||||
return icons[type] || 'article';
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const fetchLessonDetail = async () => {
|
||||
// Always verify lesson and courseId exist
|
||||
if (!props.lesson || !props.courseId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await instructorService.getLesson(
|
||||
props.courseId,
|
||||
props.lesson.chapter_id,
|
||||
props.lesson.id
|
||||
);
|
||||
console.log('Fetched Lesson Details:', data);
|
||||
|
||||
// FIX: Handle case where API returns Quiz object directly instead of LessonDetailResponse
|
||||
if (props.lesson.type === 'QUIZ' && !data.quiz && (data as any).questions) {
|
||||
console.warn('API returned Quiz object directly, wrapping it.');
|
||||
lessonDetail.value = {
|
||||
...props.lesson,
|
||||
attachments: [], // Assume no attachments if structure is unexpected
|
||||
quiz: data as any
|
||||
};
|
||||
} else {
|
||||
lessonDetail.value = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch lesson detail:', error);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูลบทเรียนได้'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
fetchLessonDetail();
|
||||
} else {
|
||||
// Optional: clear detail to avoid flashing old content next time
|
||||
// lessonDetail.value = null;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Also watch lesson ID in case dialog stays open but lesson changes
|
||||
watch(() => props.lesson.id, () => {
|
||||
if (props.modelValue) {
|
||||
fetchLessonDetail();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -3,14 +3,15 @@
|
|||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">โครงสร้างบทเรียน</h2>
|
||||
<q-btn
|
||||
v-if="course.status === 'DRAFT'"
|
||||
color="primary"
|
||||
label="จัดการโครงสร้าง"
|
||||
@click="navigateTo(`/instructor/courses/${courseId}/structure`)"
|
||||
@click="navigateTo(`/instructor/courses/${course.id}/structure`)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chapters -->
|
||||
<div v-if="chapters.length === 0" class="text-center py-10 text-gray-500">
|
||||
<div v-if="course.chapters.length === 0" class="text-center py-10 text-gray-500">
|
||||
ยังไม่มีบทเรียน
|
||||
</div>
|
||||
|
||||
|
|
@ -41,6 +42,10 @@
|
|||
v-for="lesson in getSortedLessons(chapter)"
|
||||
:key="lesson.id"
|
||||
class="py-3"
|
||||
:class="{ 'cursor-pointer hover:bg-gray-50': course.status === 'APPROVED' }"
|
||||
:clickable="course.status === 'APPROVED'"
|
||||
:v-ripple="course.status === 'APPROVED'"
|
||||
@click="handleLessonClick(lesson)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon
|
||||
|
|
@ -53,26 +58,38 @@
|
|||
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side v-if="course.status === 'APPROVED'">
|
||||
<q-icon name="visibility" size="xs" color="grey-5" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Lesson Preview Dialog -->
|
||||
<LessonPreviewDialog
|
||||
v-if="previewLesson"
|
||||
v-model="showPreview"
|
||||
:lesson="previewLesson"
|
||||
:course-id="course.id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChapterResponse } from '~/services/instructor.service';
|
||||
import type { CourseDetailResponse, ChapterResponse, LessonResponse } from '~/services/instructor.service';
|
||||
import LessonPreviewDialog from './LessonPreviewDialog.vue';
|
||||
|
||||
interface Props {
|
||||
courseId: number;
|
||||
chapters: ChapterResponse[];
|
||||
course: CourseDetailResponse;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Computed
|
||||
const sortedChapters = computed(() => {
|
||||
return props.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||
return props.course.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||
});
|
||||
|
||||
// Methods
|
||||
|
|
@ -101,4 +118,15 @@ const getLessonIconColor = (type: string) => {
|
|||
};
|
||||
return colors[type] || 'grey';
|
||||
};
|
||||
|
||||
// Preview
|
||||
const showPreview = ref(false);
|
||||
const previewLesson = ref<LessonResponse | null>(null);
|
||||
|
||||
const handleLessonClick = (lesson: LessonResponse) => {
|
||||
if (props.course.status === 'APPROVED') {
|
||||
previewLesson.value = lesson;
|
||||
showPreview.value = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ const getLessonIcon = (type: string) => {
|
|||
const getLessonTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
VIDEO: 'blue',
|
||||
QUIZ: 'purple',
|
||||
QUIZ: 'orange',
|
||||
DOCUMENT: 'teal'
|
||||
};
|
||||
return colors[type] || 'grey';
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@
|
|||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-primary-600">คอร์สรออนุมัติ</h1>
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
label="รีเฟรช"
|
||||
icon="refresh"
|
||||
:loading="loading"
|
||||
@click="fetchPendingCourses"
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
label="รีเฟรช"
|
||||
icon="refresh"
|
||||
:loading="loading"
|
||||
@click="fetchPendingCourses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
|
|
@ -47,6 +49,17 @@
|
|||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-6">
|
||||
<q-btn-toggle
|
||||
v-model="viewMode"
|
||||
toggle-color="primary"
|
||||
:options="[
|
||||
{ label: 'การ์ด', value: 'card' },
|
||||
{ label: 'ตาราง', value: 'table' }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pending Courses List -->
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<q-spinner color="primary" size="48px" />
|
||||
|
|
@ -57,7 +70,8 @@
|
|||
<p class="text-gray-500 mt-4">ไม่มีคอร์สที่รอการอนุมัติ</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Card View -->
|
||||
<div v-else-if="viewMode === 'card'" class="space-y-4">
|
||||
<div
|
||||
v-for="course in filteredCourses"
|
||||
:key="course.id"
|
||||
|
|
@ -125,6 +139,78 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<q-table
|
||||
:rows="filteredCourses"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
flat
|
||||
bordered
|
||||
:pagination="{ rowsPerPage: 10 }"
|
||||
>
|
||||
<!-- Thumbnail Slot -->
|
||||
<template v-slot:body-cell-thumbnail="props">
|
||||
<q-td :props="props">
|
||||
<div class="w-16 h-10 rounded overflow-hidden bg-gray-100">
|
||||
<img
|
||||
v-if="props.row.thumbnail_url"
|
||||
:src="props.row.thumbnail_url"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<q-icon name="school" color="grey" />
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Title Slot -->
|
||||
<template v-slot:body-cell-title="props">
|
||||
<q-td :props="props">
|
||||
<div class="font-semibold">{{ props.row.title.th }}</div>
|
||||
<div class="text-xs text-gray-500">{{ props.row.title.en }}</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Stats Slot -->
|
||||
<template v-slot:body-cell-stats="props">
|
||||
<q-td :props="props">
|
||||
<div class="text-xs">
|
||||
<div>{{ props.row.chapters_count }} บท</div>
|
||||
<div>{{ props.row.lessons_count }} บทเรียน</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Submission Slot -->
|
||||
<template v-slot:body-cell-submitted_at="props">
|
||||
<q-td :props="props">
|
||||
<div v-if="props.row.latest_submission">
|
||||
<div class="text-xs">{{ formatDate(props.row.latest_submission.created_at) }}</div>
|
||||
<div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Actions Slot -->
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
color="primary"
|
||||
icon="visibility"
|
||||
@click="viewCourse(props.row)"
|
||||
>
|
||||
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -144,6 +230,16 @@ const router = useRouter();
|
|||
const courses = ref<PendingCourse[]>([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const viewMode = ref('table');
|
||||
|
||||
const columns = [
|
||||
{ name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' },
|
||||
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: PendingCourse) => row.title.th, align: 'left', sortable: true },
|
||||
{ name: 'instructor', label: 'ผู้สอน', field: (row: PendingCourse) => getPrimaryInstructor(row), align: 'left', sortable: true },
|
||||
{ name: 'stats', label: 'จำนวนบท', field: 'stats', align: 'center' },
|
||||
{ name: 'submitted_at', label: 'วันที่ส่ง', field: (row: PendingCourse) => row.latest_submission?.created_at, align: 'left', sortable: true },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
||||
];
|
||||
|
||||
// Computed
|
||||
const totalChapters = computed(() =>
|
||||
|
|
|
|||
|
|
@ -230,9 +230,10 @@
|
|||
:color="choice.is_correct ? 'positive' : 'grey'"
|
||||
size="18px"
|
||||
/>
|
||||
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
|
||||
{{ choice.text.th || `ตัวเลือก ${Number(cIndex) + 1}` }}
|
||||
</span>
|
||||
<div :class="{ 'text-green-600 font-medium': choice.is_correct }">
|
||||
<div>{{ choice.text.th || `ตัวเลือก ${Number(cIndex) + 1}` }}</div>
|
||||
<div v-if="choice.text.en" class="text-xs opacity-75">{{ choice.text.en }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
|
@ -296,13 +297,21 @@
|
|||
:val="cIndex"
|
||||
@update:model-value="setCorrectChoice(cIndex)"
|
||||
/>
|
||||
<q-input
|
||||
v-model="choice.text.th"
|
||||
:label="`ตัวเลือก ${cIndex + 1}`"
|
||||
outlined
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<q-input
|
||||
v-model="choice.text.th"
|
||||
:label="`ตัวเลือก ${cIndex + 1} (TH)`"
|
||||
outlined
|
||||
dense
|
||||
class="mb-2"
|
||||
/>
|
||||
<q-input
|
||||
v-model="choice.text.en"
|
||||
:label="`ตัวเลือก ${cIndex + 1} (EN)`"
|
||||
outlined
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
v-if="questionForm.choices.length > 2"
|
||||
flat
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@
|
|||
<q-tab-panels v-model="activeTab" class="bg-white rounded-b-xl shadow-sm">
|
||||
<!-- Structure Tab -->
|
||||
<q-tab-panel name="structure" class="p-6">
|
||||
<CourseStructureTab :course-id="course.id" :chapters="course.chapters" />
|
||||
<CourseStructureTab :course="course" />
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- Students Tab -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue