feat: Add initial frontend setup including authentication, instructor, and admin course management modules.
This commit is contained in:
parent
9fd217e1db
commit
19844f343b
16 changed files with 2065 additions and 293 deletions
|
|
@ -26,6 +26,15 @@
|
||||||
<span>จัดการหลักสูตร</span>
|
<span>จัดการหลักสูตร</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/courses/pending"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
<q-icon name="pending_actions" size="24px" />
|
||||||
|
<span>คอร์สรออนุมัติ</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/users"
|
to="/admin/users"
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
|
|
||||||
21
frontend_management/package-lock.json
generated
21
frontend_management/package-lock.json
generated
|
|
@ -13,7 +13,8 @@
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"quasar": "^2.18.6",
|
"quasar": "^2.18.6",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|
@ -10187,6 +10188,12 @@
|
||||||
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
|
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.6",
|
"version": "0.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||||
|
|
@ -11900,6 +11907,18 @@
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vuedraggable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "1.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"quasar": "^2.18.6",
|
"quasar": "^2.18.6",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|
|
||||||
445
frontend_management/pages/admin/courses/[id].vue
Normal file
445
frontend_management/pages/admin/courses/[id].vue
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<q-spinner color="primary" size="64px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="bg-white rounded-xl shadow-sm p-12 text-center">
|
||||||
|
<q-icon name="error_outline" size="64px" color="negative" />
|
||||||
|
<p class="text-gray-500 mt-4">{{ error }}</p>
|
||||||
|
<q-btn color="primary" label="กลับ" class="mt-4" @click="router.back()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Course Detail -->
|
||||||
|
<div v-else-if="course">
|
||||||
|
<!-- Header with Actions -->
|
||||||
|
<div class="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<q-btn flat icon="arrow_back" label="กลับ" @click="router.back()" class="mb-2" />
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{{ course.title.th }}</h1>
|
||||||
|
<p class="text-gray-500">{{ course.title.en }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<q-btn
|
||||||
|
color="positive"
|
||||||
|
label="อนุมัติคอร์ส"
|
||||||
|
icon="check_circle"
|
||||||
|
@click="confirmApprove"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
color="negative"
|
||||||
|
label="ปฏิเสธ"
|
||||||
|
icon="cancel"
|
||||||
|
outline
|
||||||
|
@click="showRejectModal = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Course Info Cards -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
|
<!-- Main Info -->
|
||||||
|
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<div class="h-48 bg-gray-100 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
v-if="course.thumbnail_url"
|
||||||
|
:src="course.thumbnail_url"
|
||||||
|
:alt="course.title.th"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<q-icon v-else name="school" size="80px" color="grey-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" />
|
||||||
|
<q-badge color="grey" :label="course.category.name.th" />
|
||||||
|
<q-badge v-if="course.is_free" color="green" label="ฟรี" />
|
||||||
|
<q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
|
||||||
|
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="font-semibold text-gray-700 mb-2">รายละเอียด</h3>
|
||||||
|
<p class="text-gray-600 whitespace-pre-line">{{ course.description.th }}</p>
|
||||||
|
|
||||||
|
<div class="border-t mt-4 pt-4">
|
||||||
|
<p class="text-sm text-gray-500">{{ course.description.en }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Side Info -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h3 class="font-semibold text-gray-700 mb-4">สถิติ</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">จำนวนบท</span>
|
||||||
|
<span class="font-medium">{{ course.chapters.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">จำนวนบทเรียน</span>
|
||||||
|
<span class="font-medium">{{ totalLessons }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">วิดีโอ</span>
|
||||||
|
<span class="font-medium">{{ videoCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">แบบทดสอบ</span>
|
||||||
|
<span class="font-medium">{{ quizCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructors -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h3 class="font-semibold text-gray-700 mb-4">ผู้สอน</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="instructor in course.instructors"
|
||||||
|
:key="instructor.user_id"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||||
|
<q-icon name="person" color="primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ instructor.user.username }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ instructor.user.email }}</div>
|
||||||
|
<q-badge v-if="instructor.is_primary" color="primary" label="หลัก" size="sm" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h3 class="font-semibold text-gray-700 mb-4">ไทม์ไลน์</h3>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">สร้างเมื่อ</span>
|
||||||
|
<span>{{ formatDate(course.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">อัพเดทล่าสุด</span>
|
||||||
|
<span>{{ formatDate(course.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Course Structure -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<h3 class="font-semibold text-gray-700 mb-4">โครงสร้างหลักสูตร</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<q-expansion-item
|
||||||
|
v-for="(chapter, index) in course.chapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
:label="`บทที่ ${index + 1}: ${chapter.title.th}`"
|
||||||
|
:caption="`${chapter.lessons.length} บทเรียน`"
|
||||||
|
header-class="bg-gray-50 rounded-lg"
|
||||||
|
expand-icon-class="text-primary"
|
||||||
|
>
|
||||||
|
<div class="pl-4 pt-2">
|
||||||
|
<div
|
||||||
|
v-for="(lesson, lessonIndex) in chapter.lessons"
|
||||||
|
:key="lesson.id"
|
||||||
|
class="flex items-center gap-3 py-2 border-b last:border-b-0"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
:name="getLessonIcon(lesson.type)"
|
||||||
|
:color="lesson.is_published ? 'primary' : 'grey'"
|
||||||
|
size="24px"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-gray-600">{{ lessonIndex + 1 }}. {{ lesson.title.th }}</span>
|
||||||
|
<q-badge
|
||||||
|
v-if="!lesson.is_published"
|
||||||
|
color="grey"
|
||||||
|
label="Draft"
|
||||||
|
size="sm"
|
||||||
|
class="ml-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<q-badge :color="getLessonTypeColor(lesson.type)" :label="lesson.type" size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Approval History -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h3 class="font-semibold text-gray-700 mb-4">ประวัติการอนุมัติ</h3>
|
||||||
|
|
||||||
|
<q-timeline color="primary">
|
||||||
|
<q-timeline-entry
|
||||||
|
v-for="history in course.approval_history"
|
||||||
|
:key="history.id"
|
||||||
|
:title="getActionLabel(history.action)"
|
||||||
|
:subtitle="formatDateTime(history.created_at)"
|
||||||
|
:icon="getActionIcon(history.action)"
|
||||||
|
:color="getActionColor(history.action)"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<p>โดย: {{ history.submitter.username }}</p>
|
||||||
|
<p v-if="history.reviewer">ผู้ตรวจสอบ: {{ history.reviewer.username }}</p>
|
||||||
|
<p v-if="history.comment" class="mt-2 p-2 bg-gray-50 rounded">{{ history.comment }}</p>
|
||||||
|
</div>
|
||||||
|
</q-timeline-entry>
|
||||||
|
</q-timeline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reject Dialog -->
|
||||||
|
<q-dialog v-model="showRejectModal" persistent>
|
||||||
|
<q-card style="min-width: 400px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">ปฏิเสธคอร์ส</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="showRejectModal = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
กรุณาระบุเหตุผลในการปฏิเสธคอร์ส "{{ course?.title.th }}"
|
||||||
|
</p>
|
||||||
|
<q-input
|
||||||
|
v-model="rejectReason"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
rows="4"
|
||||||
|
label="เหตุผล *"
|
||||||
|
:rules="[val => !!val || 'กรุณาระบุเหตุผล']"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right" class="q-pa-md">
|
||||||
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="showRejectModal = false" />
|
||||||
|
<q-btn
|
||||||
|
label="ยืนยันการปฏิเสธ"
|
||||||
|
color="negative"
|
||||||
|
:loading="actionLoading"
|
||||||
|
:disable="!rejectReason.trim()"
|
||||||
|
@click="confirmReject"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { adminService, type CourseDetailForReview } from '~/services/admin.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['auth', 'admin']
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const course = ref<CourseDetailForReview | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const actionLoading = ref(false);
|
||||||
|
const showRejectModal = ref(false);
|
||||||
|
const rejectReason = ref('');
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const totalLessons = computed(() =>
|
||||||
|
course.value?.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoCount = computed(() =>
|
||||||
|
course.value?.chapters.reduce((sum, ch) =>
|
||||||
|
sum + ch.lessons.filter(l => l.type === 'VIDEO').length, 0
|
||||||
|
) || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const quizCount = computed(() =>
|
||||||
|
course.value?.chapters.reduce((sum, ch) =>
|
||||||
|
sum + ch.lessons.filter(l => l.type === 'QUIZ').length, 0
|
||||||
|
) || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchCourse = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const courseId = Number(route.params.id);
|
||||||
|
course.value = await adminService.getCourseForReview(courseId);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'ไม่สามารถโหลดข้อมูลคอร์สได้';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
DRAFT: 'grey',
|
||||||
|
PENDING: 'orange',
|
||||||
|
APPROVED: 'positive',
|
||||||
|
REJECTED: 'negative'
|
||||||
|
};
|
||||||
|
return colors[status] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
DRAFT: 'ฉบับร่าง',
|
||||||
|
PENDING: 'รอตรวจสอบ',
|
||||||
|
APPROVED: 'อนุมัติแล้ว',
|
||||||
|
REJECTED: 'ถูกปฏิเสธ'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonIcon = (type: string) => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
VIDEO: 'play_circle',
|
||||||
|
QUIZ: 'quiz',
|
||||||
|
DOCUMENT: 'description'
|
||||||
|
};
|
||||||
|
return icons[type] || 'article';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonTypeColor = (type: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
VIDEO: 'blue',
|
||||||
|
QUIZ: 'purple',
|
||||||
|
DOCUMENT: 'teal'
|
||||||
|
};
|
||||||
|
return colors[type] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionLabel = (action: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
SUBMITTED: 'ส่งเพื่อตรวจสอบ',
|
||||||
|
APPROVED: 'อนุมัติ',
|
||||||
|
REJECTED: 'ปฏิเสธ',
|
||||||
|
RESUBMITTED: 'ส่งใหม่'
|
||||||
|
};
|
||||||
|
return labels[action] || action;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionIcon = (action: string) => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
SUBMITTED: 'send',
|
||||||
|
APPROVED: 'check_circle',
|
||||||
|
REJECTED: 'cancel',
|
||||||
|
RESUBMITTED: 'replay'
|
||||||
|
};
|
||||||
|
return icons[action] || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionColor = (action: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
SUBMITTED: 'primary',
|
||||||
|
APPROVED: 'positive',
|
||||||
|
REJECTED: 'negative',
|
||||||
|
RESUBMITTED: 'orange'
|
||||||
|
};
|
||||||
|
return colors[action] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmApprove = () => {
|
||||||
|
if (!course.value) return;
|
||||||
|
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ยืนยันการอนุมัติ',
|
||||||
|
message: `คุณต้องการอนุมัติคอร์ส "${course.value.title.th}" หรือไม่?`,
|
||||||
|
persistent: true,
|
||||||
|
ok: {
|
||||||
|
label: 'อนุมัติ',
|
||||||
|
color: 'positive'
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
label: 'ยกเลิก',
|
||||||
|
flat: true
|
||||||
|
}
|
||||||
|
}).onOk(async () => {
|
||||||
|
actionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await adminService.approveCourse(course.value!.id);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'อนุมัติคอร์สสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
router.push('/admin/courses/pending');
|
||||||
|
} catch (err) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการอนุมัติคอร์ส',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmReject = async () => {
|
||||||
|
if (!course.value || !rejectReason.value.trim()) return;
|
||||||
|
|
||||||
|
actionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await adminService.rejectCourse(course.value.id, rejectReason.value);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ปฏิเสธคอร์สสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
showRejectModal.value = false;
|
||||||
|
router.push('/admin/courses/pending');
|
||||||
|
} catch (err) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการปฏิเสธคอร์ส',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCourse();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
218
frontend_management/pages/admin/courses/pending.vue
Normal file
218
frontend_management/pages/admin/courses/pending.vue
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
|
||||||
|
<div class="text-4xl font-bold text-orange-500">{{ courses.length }}</div>
|
||||||
|
<div class="text-gray-500 mt-1">รอตรวจสอบ</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
|
||||||
|
<div class="text-4xl font-bold text-gray-700">{{ totalChapters }}</div>
|
||||||
|
<div class="text-gray-500 mt-1">บททั้งหมด</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
|
||||||
|
<div class="text-4xl font-bold text-gray-700">{{ totalLessons }}</div>
|
||||||
|
<div class="text-gray-500 mt-1">บทเรียนทั้งหมด</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||||
|
<q-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
bg-color="grey-1"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:append v-if="searchQuery">
|
||||||
|
<q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Courses List -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<q-spinner color="primary" size="48px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-12 text-center">
|
||||||
|
<q-icon name="pending_actions" size="64px" color="grey-4" />
|
||||||
|
<p class="text-gray-500 mt-4">ไม่มีคอร์สที่รอการอนุมัติ</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="course in filteredCourses"
|
||||||
|
:key="course.id"
|
||||||
|
class="bg-white rounded-xl shadow-sm overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col md:flex-row">
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<div class="md:w-48 h-32 md:h-auto bg-gray-100 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="course.thumbnail_url"
|
||||||
|
:src="course.thumbnail_url"
|
||||||
|
:alt="course.title.th"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<q-icon name="school" size="48px" color="grey-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Course Info -->
|
||||||
|
<div class="flex-1 p-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">{{ course.title.th }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{ course.title.en }}</p>
|
||||||
|
|
||||||
|
<p class="text-gray-600 mt-2 line-clamp-2">{{ course.description.th }}</p>
|
||||||
|
|
||||||
|
<!-- Meta Info -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mt-3">
|
||||||
|
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<q-icon name="person" size="18px" />
|
||||||
|
<span>{{ getPrimaryInstructor(course) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<q-icon name="folder" size="18px" />
|
||||||
|
<span>{{ course.chapters_count }} บท</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<q-icon name="play_circle" size="18px" />
|
||||||
|
<span>{{ course.lessons_count }} บทเรียน</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submission Info -->
|
||||||
|
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
|
||||||
|
<q-icon name="send" size="16px" class="mr-1" />
|
||||||
|
ส่งโดย {{ course.latest_submission.submitter.username }}
|
||||||
|
เมื่อ {{ formatDate(course.latest_submission.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex md:flex-col gap-2">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="ดูรายละเอียด"
|
||||||
|
icon="visibility"
|
||||||
|
class="flex-1 md:flex-none"
|
||||||
|
@click="viewCourse(course)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { adminService, type PendingCourse } from '~/services/admin.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['auth', 'admin']
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const courses = ref<PendingCourse[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const totalChapters = computed(() =>
|
||||||
|
courses.value.reduce((sum, c) => sum + c.chapters_count, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalLessons = computed(() =>
|
||||||
|
courses.value.reduce((sum, c) => sum + c.lessons_count, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredCourses = computed(() => {
|
||||||
|
if (!searchQuery.value) return courses.value;
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
return courses.value.filter(course =>
|
||||||
|
course.title.th.toLowerCase().includes(query) ||
|
||||||
|
course.title.en.toLowerCase().includes(query) ||
|
||||||
|
course.creator.username.toLowerCase().includes(query) ||
|
||||||
|
course.creator.email.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchPendingCourses = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
courses.value = await adminService.getPendingCourses();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPrimaryInstructor = (course: PendingCourse) => {
|
||||||
|
const primary = course.instructors.find(i => i.is_primary);
|
||||||
|
return primary?.user.username || course.creator.username;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewCourse = (course: PendingCourse) => {
|
||||||
|
router.push(`/admin/courses/${course.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPendingCourses();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -116,6 +116,15 @@
|
||||||
label="แสดงเฉลยหลังทำเสร็จ"
|
label="แสดงเฉลยหลังทำเสร็จ"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4 text-right">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="บันทึกการตั้งค่า"
|
||||||
|
icon="save"
|
||||||
|
:loading="savingSettings"
|
||||||
|
@click="saveQuizSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
|
|
@ -133,44 +142,56 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Questions List -->
|
<!-- Questions List -->
|
||||||
<div v-if="questions.length > 0" class="space-y-4">
|
<draggable
|
||||||
<q-card
|
v-if="questions.length > 0"
|
||||||
v-for="(question, qIndex) in questions"
|
v-model="questions"
|
||||||
:key="qIndex"
|
item-key="id"
|
||||||
flat
|
handle=".drag-handle"
|
||||||
bordered
|
animation="200"
|
||||||
class="bg-gray-50"
|
ghost-class="opacity-50"
|
||||||
>
|
class="space-y-4"
|
||||||
<q-card-section>
|
@end="onDragEnd"
|
||||||
<div class="flex justify-between items-start mb-3">
|
>
|
||||||
<span class="font-medium">คำถามที่ {{ qIndex + 1 }}</span>
|
<template #item="{ element: question, index: qIndex }">
|
||||||
<div class="flex gap-1">
|
<q-card flat bordered class="bg-gray-50">
|
||||||
<q-btn flat round dense icon="edit" size="sm" @click="editQuestion(qIndex)" />
|
<q-card-section>
|
||||||
<q-btn flat round dense icon="delete" color="negative" size="sm" @click="removeQuestion(qIndex)" />
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<q-icon
|
||||||
|
name="drag_indicator"
|
||||||
|
class="drag-handle cursor-grab text-gray-400 hover:text-gray-600"
|
||||||
|
size="20px"
|
||||||
|
/>
|
||||||
|
<span class="font-medium">คำถามที่ {{ qIndex + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<q-btn flat round dense icon="edit" size="sm" @click="editQuestion(qIndex)" />
|
||||||
|
<q-btn flat round dense icon="delete" color="negative" size="sm" @click="removeQuestion(qIndex)" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mb-2">{{ question.text.th || '(ยังไม่ได้กรอกคำถาม)' }}</p>
|
||||||
<p class="mb-2">{{ question.text.th || '(ยังไม่ได้กรอกคำถาม)' }}</p>
|
|
||||||
|
|
||||||
<!-- Choices Preview -->
|
<!-- Choices Preview -->
|
||||||
<div class="pl-4 space-y-1">
|
<div class="pl-4 space-y-1">
|
||||||
<div
|
<div
|
||||||
v-for="(choice, cIndex) in question.choices"
|
v-for="(choice, cIndex) in question.choices"
|
||||||
:key="cIndex"
|
:key="cIndex"
|
||||||
class="flex items-center gap-2 text-sm"
|
class="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<q-icon
|
<q-icon
|
||||||
:name="choice.is_correct ? 'check_circle' : 'radio_button_unchecked'"
|
:name="choice.is_correct ? 'check_circle' : 'radio_button_unchecked'"
|
||||||
:color="choice.is_correct ? 'positive' : 'grey'"
|
:color="choice.is_correct ? 'positive' : 'grey'"
|
||||||
size="18px"
|
size="18px"
|
||||||
/>
|
/>
|
||||||
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
|
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
|
||||||
{{ choice.text.th || `ตัวเลือก ${cIndex + 1}` }}
|
{{ choice.text.th || `ตัวเลือก ${cIndex + 1}` }}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</q-card-section>
|
||||||
</q-card-section>
|
</q-card>
|
||||||
</q-card>
|
</template>
|
||||||
</div>
|
</draggable>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else class="text-center py-8 text-gray-500">
|
<div v-else class="text-center py-8 text-gray-500">
|
||||||
|
|
@ -264,6 +285,7 @@
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
import { instructorService, type LessonResponse, type CreateQuestionRequest } from '~/services/instructor.service';
|
import { instructorService, type LessonResponse, type CreateQuestionRequest } from '~/services/instructor.service';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
@ -290,6 +312,50 @@ const quizSettings = ref({
|
||||||
show_answers_after_completion: true
|
show_answers_after_completion: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const savingSettings = ref(false);
|
||||||
|
|
||||||
|
const saveQuizSettings = async () => {
|
||||||
|
savingSettings.value = true;
|
||||||
|
try {
|
||||||
|
await instructorService.updateQuizSettings(courseId, chapterId, lessonId, {
|
||||||
|
title: form.value.title,
|
||||||
|
description: form.value.content,
|
||||||
|
passing_score: quizSettings.value.passing_score,
|
||||||
|
time_limit: quizSettings.value.time_limit,
|
||||||
|
shuffle_questions: quizSettings.value.shuffle_questions,
|
||||||
|
shuffle_choices: quizSettings.value.shuffle_choices,
|
||||||
|
show_answers_after_completion: quizSettings.value.show_answers_after_completion
|
||||||
|
});
|
||||||
|
$q.notify({ type: 'positive', message: 'บันทึกการตั้งค่าสำเร็จ', position: 'top' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save quiz settings:', error);
|
||||||
|
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกการตั้งค่าได้', position: 'top' });
|
||||||
|
} finally {
|
||||||
|
savingSettings.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and Drop handler (vuedraggable)
|
||||||
|
const onDragEnd = async (event: { oldIndex: number; newIndex: number }) => {
|
||||||
|
const { oldIndex, newIndex } = event;
|
||||||
|
if (oldIndex === newIndex) return;
|
||||||
|
|
||||||
|
const question = questions.value[newIndex];
|
||||||
|
if (!question.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call API with new position (1-indexed)
|
||||||
|
await instructorService.reorderQuestion(courseId, chapterId, lessonId, question.id, newIndex + 1);
|
||||||
|
$q.notify({ type: 'positive', message: 'เรียงลำดับคำถามสำเร็จ', position: 'top' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder question:', error);
|
||||||
|
// Revert on error - swap back
|
||||||
|
const [item] = questions.value.splice(newIndex, 1);
|
||||||
|
questions.value.splice(oldIndex, 0, item);
|
||||||
|
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface QuizChoice {
|
interface QuizChoice {
|
||||||
id?: number;
|
id?: number;
|
||||||
text: { th: string; en: string };
|
text: { th: string; en: string };
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref="attachmentInput"
|
:ref="(el: any) => attachmentInput = el"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
|
|
@ -326,6 +326,7 @@ const uploadingAttachment = ref(false);
|
||||||
const deletingAttachmentId = ref<number | null>(null);
|
const deletingAttachmentId = ref<number | null>(null);
|
||||||
|
|
||||||
const triggerAttachmentInput = () => {
|
const triggerAttachmentInput = () => {
|
||||||
|
console.log('Trigger attachment input:', attachmentInput.value);
|
||||||
attachmentInput.value?.click();
|
attachmentInput.value?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,12 +167,205 @@
|
||||||
|
|
||||||
<!-- Announcements Tab -->
|
<!-- Announcements Tab -->
|
||||||
<q-tab-panel name="announcements" class="p-6">
|
<q-tab-panel name="announcements" class="p-6">
|
||||||
<div class="text-center py-10 text-gray-500">
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">ประกาศ</h2>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="+ สร้างประกาศ"
|
||||||
|
icon="add"
|
||||||
|
@click="openAnnouncementDialog()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingAnnouncements" class="flex justify-center py-10">
|
||||||
|
<q-spinner color="primary" size="40px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="announcements.length === 0" class="text-center py-10 text-gray-500">
|
||||||
<q-icon name="campaign" size="60px" color="grey-4" class="mb-4" />
|
<q-icon name="campaign" size="60px" color="grey-4" class="mb-4" />
|
||||||
<p>ยังไม่มีประกาศ</p>
|
<p>ยังไม่มีประกาศ</p>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="สร้างประกาศแรก"
|
||||||
|
class="mt-4"
|
||||||
|
@click="openAnnouncementDialog()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<q-card
|
||||||
|
v-for="announcement in announcements"
|
||||||
|
:key="announcement.id"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="rounded-lg"
|
||||||
|
>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<q-icon v-if="announcement.is_pinned" name="push_pin" color="orange" size="20px" />
|
||||||
|
<h3 class="font-semibold text-gray-900">{{ announcement.title.th }}</h3>
|
||||||
|
<q-badge :color="announcement.status === 'PUBLISHED' ? 'green' : 'grey'">
|
||||||
|
{{ announcement.status === 'PUBLISHED' ? 'เผยแพร่' : 'ฉบับร่าง' }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{ announcement.title.en }}</p>
|
||||||
|
<p class="text-gray-600 mt-3 whitespace-pre-line">{{ announcement.content.th }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-3">
|
||||||
|
สร้างเมื่อ {{ formatDate(announcement.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<q-btn flat round icon="edit" color="primary" size="sm" @click="openAnnouncementDialog(announcement)" />
|
||||||
|
<q-btn flat round icon="delete" color="negative" size="sm" @click="confirmDeleteAnnouncement(announcement)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
|
|
||||||
|
<!-- Announcement Dialog -->
|
||||||
|
<q-dialog v-model="showAnnouncementDialog" persistent>
|
||||||
|
<q-card style="min-width: 600px; max-width: 700px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">{{ editingAnnouncement ? 'แก้ไขประกาศ' : 'สร้างประกาศใหม่' }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="showAnnouncementDialog = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<q-input
|
||||||
|
v-model="announcementForm.title.th"
|
||||||
|
outlined
|
||||||
|
label="หัวข้อ (ภาษาไทย) *"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกหัวข้อ']"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="announcementForm.title.en"
|
||||||
|
outlined
|
||||||
|
label="หัวข้อ (English)"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="announcementForm.content.th"
|
||||||
|
outlined
|
||||||
|
type="textarea"
|
||||||
|
rows="4"
|
||||||
|
label="เนื้อหา (ภาษาไทย) *"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="announcementForm.content.en"
|
||||||
|
outlined
|
||||||
|
type="textarea"
|
||||||
|
rows="4"
|
||||||
|
label="เนื้อหา (English)"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<q-toggle v-model="announcementForm.is_pinned" label="ปักหมุด" />
|
||||||
|
<q-select
|
||||||
|
v-model="announcementForm.status"
|
||||||
|
:options="[
|
||||||
|
{ label: 'ฉบับร่าง', value: 'DRAFT' },
|
||||||
|
{ label: 'เผยแพร่', value: 'PUBLISHED' }
|
||||||
|
]"
|
||||||
|
outlined
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
label="สถานะ"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachments Section -->
|
||||||
|
<div class="border rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h4 class="font-semibold text-gray-700">ไฟล์แนบ</h4>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
icon="attach_file"
|
||||||
|
label="เพิ่มไฟล์"
|
||||||
|
:loading="uploadingAttachment"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Attachments (edit mode) -->
|
||||||
|
<div v-if="editingAnnouncement?.attachments?.length > 0" class="space-y-2 mb-2">
|
||||||
|
<div
|
||||||
|
v-for="attachment in editingAnnouncement.attachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
class="flex items-center justify-between bg-gray-50 rounded p-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<q-icon name="insert_drive_file" color="grey" />
|
||||||
|
<span class="text-sm">{{ attachment.file_name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">({{ formatFileSize(attachment.file_size) }})</span>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
size="sm"
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
:loading="deletingAttachmentId === attachment.id"
|
||||||
|
@click="deleteAttachment(attachment.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Files (create mode) -->
|
||||||
|
<div v-if="pendingFiles.length > 0" class="space-y-2 mb-2">
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in pendingFiles"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between bg-blue-50 rounded p-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<q-icon name="upload_file" color="primary" />
|
||||||
|
<span class="text-sm">{{ file.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">({{ formatFileSize(file.size) }})</span>
|
||||||
|
<q-badge color="blue" label="รอสร้าง" size="sm" />
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
size="sm"
|
||||||
|
icon="close"
|
||||||
|
color="grey"
|
||||||
|
@click="removePendingFile(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!editingAnnouncement?.attachments?.length && pendingFiles.length === 0" class="text-center py-4 text-gray-400 text-sm">
|
||||||
|
ยังไม่มีไฟล์แนบ
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right" class="q-pa-md">
|
||||||
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="showAnnouncementDialog = false" />
|
||||||
|
<q-btn
|
||||||
|
:label="editingAnnouncement ? 'บันทึก' : 'สร้าง'"
|
||||||
|
color="primary"
|
||||||
|
:loading="savingAnnouncement"
|
||||||
|
@click="saveAnnouncement"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -182,7 +375,9 @@ import { useQuasar } from 'quasar';
|
||||||
import {
|
import {
|
||||||
instructorService,
|
instructorService,
|
||||||
type CourseDetailResponse,
|
type CourseDetailResponse,
|
||||||
type ChapterResponse
|
type ChapterResponse,
|
||||||
|
type AnnouncementResponse,
|
||||||
|
type CreateAnnouncementRequest
|
||||||
} from '~/services/instructor.service';
|
} from '~/services/instructor.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -198,6 +393,25 @@ const course = ref<CourseDetailResponse | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const activeTab = ref('structure');
|
const activeTab = ref('structure');
|
||||||
|
|
||||||
|
// Announcements data
|
||||||
|
const announcements = ref<AnnouncementResponse[]>([]);
|
||||||
|
const loadingAnnouncements = ref(false);
|
||||||
|
const showAnnouncementDialog = ref(false);
|
||||||
|
const editingAnnouncement = ref<AnnouncementResponse | null>(null);
|
||||||
|
const savingAnnouncement = ref(false);
|
||||||
|
const announcementForm = ref<CreateAnnouncementRequest>({
|
||||||
|
title: { th: '', en: '' },
|
||||||
|
content: { th: '', en: '' },
|
||||||
|
status: 'DRAFT',
|
||||||
|
is_pinned: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attachment handling
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const uploadingAttachment = ref(false);
|
||||||
|
const deletingAttachmentId = ref<number | null>(null);
|
||||||
|
const pendingFiles = ref<File[]>([]);
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const totalLessons = computed(() => {
|
const totalLessons = computed(() => {
|
||||||
if (!course.value) return 0;
|
if (!course.value) return 0;
|
||||||
|
|
@ -300,6 +514,224 @@ const requestApproval = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Announcements methods
|
||||||
|
const fetchAnnouncements = async () => {
|
||||||
|
loadingAnnouncements.value = true;
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
announcements.value = await instructorService.getAnnouncements(courseId);
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลประกาศได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingAnnouncements.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAnnouncementDialog = (announcement?: AnnouncementResponse) => {
|
||||||
|
if (announcement) {
|
||||||
|
editingAnnouncement.value = announcement;
|
||||||
|
announcementForm.value = {
|
||||||
|
title: { ...announcement.title },
|
||||||
|
content: { ...announcement.content },
|
||||||
|
status: announcement.status,
|
||||||
|
is_pinned: announcement.is_pinned
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
editingAnnouncement.value = null;
|
||||||
|
announcementForm.value = {
|
||||||
|
title: { th: '', en: '' },
|
||||||
|
content: { th: '', en: '' },
|
||||||
|
status: 'DRAFT',
|
||||||
|
is_pinned: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pendingFiles.value = [];
|
||||||
|
showAnnouncementDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAnnouncement = async () => {
|
||||||
|
if (!announcementForm.value.title.th || !announcementForm.value.content.th) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'กรุณากรอกหัวข้อและเนื้อหา',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savingAnnouncement.value = true;
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
if (editingAnnouncement.value) {
|
||||||
|
await instructorService.updateAnnouncement(courseId, editingAnnouncement.value.id, announcementForm.value);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'บันทึกประกาศสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create announcement with files
|
||||||
|
await instructorService.createAnnouncement(
|
||||||
|
courseId,
|
||||||
|
announcementForm.value,
|
||||||
|
pendingFiles.value.length > 0 ? pendingFiles.value : undefined
|
||||||
|
);
|
||||||
|
pendingFiles.value = [];
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'สร้างประกาศสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showAnnouncementDialog.value = false;
|
||||||
|
fetchAnnouncements();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาด',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
savingAnnouncement.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAnnouncement = (announcement: AnnouncementResponse) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ยืนยันการลบ',
|
||||||
|
message: `คุณต้องการลบประกาศ "${announcement.title.th}" หรือไม่?`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
await instructorService.deleteAnnouncement(courseId, announcement.id);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ลบประกาศสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
fetchAnnouncements();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการลบประกาศ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attachment handling methods
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// If editing existing announcement, upload immediately
|
||||||
|
if (editingAnnouncement.value) {
|
||||||
|
uploadingAttachment.value = true;
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
const updated = await instructorService.uploadAnnouncementAttachment(
|
||||||
|
courseId,
|
||||||
|
editingAnnouncement.value.id,
|
||||||
|
file
|
||||||
|
);
|
||||||
|
editingAnnouncement.value = updated;
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'อัพโหลดไฟล์สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
fetchAnnouncements();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการอัพโหลดไฟล์',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
uploadingAttachment.value = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If creating new announcement, add to pending files
|
||||||
|
pendingFiles.value.push(file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePendingFile = (index: number) => {
|
||||||
|
pendingFiles.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAttachment = async (attachmentId: number) => {
|
||||||
|
if (!editingAnnouncement.value) return;
|
||||||
|
|
||||||
|
deletingAttachmentId.value = attachmentId;
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
await instructorService.deleteAnnouncementAttachment(
|
||||||
|
courseId,
|
||||||
|
editingAnnouncement.value.id,
|
||||||
|
attachmentId
|
||||||
|
);
|
||||||
|
// Remove from local state
|
||||||
|
editingAnnouncement.value.attachments = editingAnnouncement.value.attachments.filter(
|
||||||
|
a => a.id !== attachmentId
|
||||||
|
);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ลบไฟล์สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
fetchAnnouncements();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการลบไฟล์',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
deletingAttachmentId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for tab change to load announcements
|
||||||
|
watch(activeTab, (newTab) => {
|
||||||
|
if (newTab === 'announcements' && announcements.value.length === 0) {
|
||||||
|
fetchAnnouncements();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchCourse();
|
fetchCourse();
|
||||||
|
|
|
||||||
|
|
@ -38,128 +38,101 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chapters List -->
|
<!-- Chapters List -->
|
||||||
<div v-else class="space-y-4">
|
<draggable
|
||||||
<!-- Drop indicator before first chapter -->
|
v-else
|
||||||
<div
|
v-model="chapters"
|
||||||
v-if="dragOverChapter && dragOverPosition === 'before' && sortedChapters.findIndex(ch => ch.id === dragOverChapter?.id) === 0"
|
item-key="id"
|
||||||
class="h-1.5 bg-primary rounded-full mb-2 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
handle=".chapter-handle"
|
||||||
/>
|
animation="200"
|
||||||
|
ghost-class="opacity-50"
|
||||||
<q-card
|
class="space-y-4"
|
||||||
v-for="(chapter, chapterIndex) in sortedChapters"
|
@end="onChapterDragEnd"
|
||||||
:key="chapter.id"
|
>
|
||||||
flat
|
<template #item="{ element: chapter, index: chapterIndex }">
|
||||||
bordered
|
<q-card flat bordered class="rounded-lg">
|
||||||
class="rounded-lg relative"
|
<!-- Chapter Header -->
|
||||||
:class="{ 'opacity-50': draggedChapter?.id === chapter.id }"
|
<q-card-section class="bg-gray-50">
|
||||||
draggable="true"
|
<div class="flex items-center gap-3">
|
||||||
@dragstart="onDragStart(chapter, $event)"
|
<q-icon name="drag_indicator" class="chapter-handle cursor-move text-gray-400 hover:text-gray-600" />
|
||||||
@dragend="onDragEnd"
|
<div class="flex-1">
|
||||||
@dragover.prevent="onDragOver(chapter, $event)"
|
<div class="font-semibold text-gray-900">
|
||||||
@dragleave="onDragLeave"
|
บทที่ {{ chapterIndex + 1 }}: {{ chapter.title.th }}
|
||||||
@drop.prevent="onDrop(chapter)"
|
</div>
|
||||||
>
|
<div class="text-sm text-gray-500">
|
||||||
<!-- Drop indicator line at top -->
|
{{ chapter.lessons.length }} บทเรียน
|
||||||
<div
|
</div>
|
||||||
v-if="dragOverChapter?.id === chapter.id && dragOverPosition === 'before'"
|
|
||||||
class="absolute -top-2 left-0 right-0 h-1.5 bg-primary rounded-full z-10 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Drop indicator line at bottom -->
|
|
||||||
<div
|
|
||||||
v-if="dragOverChapter?.id === chapter.id && dragOverPosition === 'after'"
|
|
||||||
class="absolute -bottom-2 left-0 right-0 h-1.5 bg-primary rounded-full z-10 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
|
||||||
/>
|
|
||||||
<!-- Chapter Header -->
|
|
||||||
<q-card-section class="bg-gray-50">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<q-icon name="drag_indicator" class="cursor-move text-gray-400" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-semibold text-gray-900">
|
|
||||||
บทที่ {{ chapter.sort_order }}: {{ chapter.title.th }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
{{ chapter.lessons.length }} บทเรียน
|
|
||||||
</div>
|
</div>
|
||||||
|
<q-btn flat dense icon="add" color="primary" @click="openLessonDialog(chapter)">
|
||||||
|
<q-tooltip>เพิ่มบทเรียน</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense icon="edit" color="grey" @click="openChapterDialog(chapter)">
|
||||||
|
<q-tooltip>แก้ไข</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense icon="delete" color="negative" @click="confirmDeleteChapter(chapter)">
|
||||||
|
<q-tooltip>ลบ</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:icon="chapter.expanded ? 'expand_less' : 'expand_more'"
|
||||||
|
@click="chapter.expanded = !chapter.expanded"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<q-btn flat dense icon="add" color="primary" @click="openLessonDialog(chapter)">
|
</q-card-section>
|
||||||
<q-tooltip>เพิ่มบทเรียน</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense icon="edit" color="grey" @click="openChapterDialog(chapter)">
|
|
||||||
<q-tooltip>แก้ไข</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense icon="delete" color="negative" @click="confirmDeleteChapter(chapter)">
|
|
||||||
<q-tooltip>ลบ</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
:icon="chapter.expanded ? 'expand_less' : 'expand_more'"
|
|
||||||
@click="chapter.expanded = !chapter.expanded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Lessons List -->
|
<!-- Lessons List -->
|
||||||
<q-slide-transition>
|
<q-slide-transition>
|
||||||
<div v-show="chapter.expanded !== false">
|
<div v-show="chapter.expanded !== false">
|
||||||
<q-list separator>
|
<draggable
|
||||||
<q-item
|
v-model="chapter.lessons"
|
||||||
v-for="(lesson, lessonIndex) in getSortedLessons(chapter.lessons)"
|
item-key="id"
|
||||||
:key="lesson.id"
|
handle=".lesson-handle"
|
||||||
class="py-3"
|
animation="200"
|
||||||
:class="{ 'opacity-50': draggedLesson?.id === lesson.id, 'bg-primary-50': dragOverLesson?.id === lesson.id }"
|
ghost-class="opacity-50"
|
||||||
draggable="true"
|
@end="(event: any) => onLessonDragEnd(chapter, event)"
|
||||||
@dragstart="onLessonDragStart(chapter, lesson, $event)"
|
|
||||||
@dragend="onLessonDragEnd"
|
|
||||||
@dragover.prevent="onLessonDragOver(chapter, lesson)"
|
|
||||||
@dragleave="onLessonDragLeave"
|
|
||||||
@drop.prevent="onLessonDrop(chapter, lesson)"
|
|
||||||
>
|
>
|
||||||
<q-item-section avatar>
|
<template #item="{ element: lesson, index: lessonIndex }">
|
||||||
<q-icon name="drag_indicator" class="cursor-move text-gray-300" />
|
<q-item class="py-3 border-b">
|
||||||
</q-item-section>
|
<q-item-section avatar>
|
||||||
<q-item-section avatar>
|
<q-icon name="drag_indicator" class="lesson-handle cursor-move text-gray-300 hover:text-gray-500" />
|
||||||
<q-icon
|
</q-item-section>
|
||||||
:name="getLessonIcon(lesson.type)"
|
<q-item-section avatar>
|
||||||
:color="getLessonIconColor(lesson.type)"
|
<q-icon
|
||||||
/>
|
:name="getLessonIcon(lesson.type)"
|
||||||
</q-item-section>
|
:color="getLessonIconColor(lesson.type)"
|
||||||
<q-item-section>
|
/>
|
||||||
<q-item-label>
|
</q-item-section>
|
||||||
{{ lesson.sort_order }}. {{ lesson.title.th }}
|
<q-item-section>
|
||||||
</q-item-label>
|
<q-item-label>
|
||||||
<q-item-label caption>
|
{{ lessonIndex + 1 }}. {{ lesson.title.th }}
|
||||||
{{ getLessonTypeLabel(lesson.type) }}
|
</q-item-label>
|
||||||
</q-item-label>
|
<q-item-label caption>
|
||||||
</q-item-section>
|
{{ getLessonTypeLabel(lesson.type) }}
|
||||||
<q-item-section side>
|
</q-item-label>
|
||||||
<div class="flex gap-1">
|
</q-item-section>
|
||||||
<q-btn flat dense icon="edit" size="sm" @click="navigateToLessonEdit(chapter, lesson)">
|
<q-item-section side>
|
||||||
<q-tooltip>แก้ไข</q-tooltip>
|
<div class="flex gap-1">
|
||||||
</q-btn>
|
<q-btn flat dense icon="edit" size="sm" @click="navigateToLessonEdit(chapter, lesson)">
|
||||||
<q-btn flat dense icon="delete" size="sm" color="negative" @click="confirmDeleteLesson(chapter, lesson)">
|
<q-tooltip>แก้ไข</q-tooltip>
|
||||||
<q-tooltip>ลบ</q-tooltip>
|
</q-btn>
|
||||||
</q-btn>
|
<q-btn flat dense icon="delete" size="sm" color="negative" @click="confirmDeleteLesson(chapter, lesson)">
|
||||||
</div>
|
<q-tooltip>ลบ</q-tooltip>
|
||||||
</q-item-section>
|
</q-btn>
|
||||||
</q-item>
|
</div>
|
||||||
</q-list>
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
<!-- Empty Lessons -->
|
<!-- Empty Lessons -->
|
||||||
<div v-if="chapter.lessons.length === 0" class="p-4 text-center text-gray-400">
|
<div v-if="chapter.lessons.length === 0" class="p-4 text-center text-gray-400">
|
||||||
ยังไม่มีบทเรียนในบทนี้
|
ยังไม่มีบทเรียนในบทนี้
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</q-slide-transition>
|
||||||
</q-slide-transition>
|
</q-card>
|
||||||
</q-card>
|
</template>
|
||||||
|
</draggable>
|
||||||
<!-- Drop indicator after last chapter -->
|
|
||||||
<div
|
|
||||||
v-if="dragOverChapter && dragOverPosition === 'after' && sortedChapters.findIndex(ch => ch.id === dragOverChapter?.id) === sortedChapters.length - 1"
|
|
||||||
class="h-1.5 bg-primary rounded-full mt-2 shadow-lg shadow-primary/50 transition-all animate-pulse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chapter Dialog -->
|
<!-- Chapter Dialog -->
|
||||||
<q-dialog v-model="chapterDialog" persistent>
|
<q-dialog v-model="chapterDialog" persistent>
|
||||||
|
|
@ -287,6 +260,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
import { instructorService, type ChapterResponse, type LessonResponse } from '~/services/instructor.service';
|
import { instructorService, type ChapterResponse, type LessonResponse } from '~/services/instructor.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -315,140 +289,42 @@ const editingChapter = ref<ChapterResponse | null>(null);
|
||||||
const editingLesson = ref<LessonResponse | null>(null);
|
const editingLesson = ref<LessonResponse | null>(null);
|
||||||
const selectedChapter = ref<ChapterResponse | null>(null);
|
const selectedChapter = ref<ChapterResponse | null>(null);
|
||||||
|
|
||||||
// Drag and Drop
|
// Drag and Drop handlers (vuedraggable)
|
||||||
const draggedChapter = ref<ChapterResponse | null>(null);
|
const onChapterDragEnd = async (event: { oldIndex: number; newIndex: number }) => {
|
||||||
const dragOverChapter = ref<ChapterResponse | null>(null);
|
const { oldIndex, newIndex } = event;
|
||||||
const dragOverPosition = ref<'before' | 'after'>('before');
|
if (oldIndex === newIndex) return;
|
||||||
|
|
||||||
const onDragStart = (chapter: ChapterResponse, event: DragEvent) => {
|
const chapter = chapters.value[newIndex];
|
||||||
draggedChapter.value = chapter;
|
if (!chapter) return;
|
||||||
if (event.dataTransfer) {
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragEnd = () => {
|
|
||||||
draggedChapter.value = null;
|
|
||||||
dragOverChapter.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragOver = (chapter: ChapterResponse, event: DragEvent) => {
|
|
||||||
if (draggedChapter.value && draggedChapter.value.id !== chapter.id) {
|
|
||||||
dragOverChapter.value = chapter;
|
|
||||||
|
|
||||||
// Determine whether we're hovering over the top half (before) or bottom half (after)
|
|
||||||
const el = event.currentTarget as HTMLElement | null;
|
|
||||||
if (el) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const y = event.clientY - rect.top;
|
|
||||||
dragOverPosition.value = y < rect.height / 2 ? 'before' : 'after';
|
|
||||||
} else {
|
|
||||||
dragOverPosition.value = 'before';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragLeave = () => {
|
|
||||||
dragOverChapter.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDrop = async (targetChapter: ChapterResponse) => {
|
|
||||||
if (!draggedChapter.value || draggedChapter.value.id === targetChapter.id) {
|
|
||||||
onDragEnd();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Insert behavior: move dragged chapter to target position (before/after target gap)
|
await instructorService.reorderChapter(courseId.value, chapter.id, newIndex + 1);
|
||||||
const sorted = chapters.value.slice().sort((a, b) => a.sort_order - b.sort_order);
|
$q.notify({ type: 'positive', message: 'เรียงลำดับบทสำเร็จ', position: 'top' });
|
||||||
const fromIndex = sorted.findIndex(ch => ch.id === draggedChapter.value!.id);
|
|
||||||
const targetIndex = sorted.findIndex(ch => ch.id === targetChapter.id);
|
|
||||||
|
|
||||||
if (fromIndex === -1 || targetIndex === -1) {
|
|
||||||
throw new Error('Chapter not found in list');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decide insert before/after based on hover position (top half => before, bottom half => after)
|
|
||||||
const desiredBeforeRemoval = dragOverPosition.value === 'before' ? targetIndex : targetIndex + 1;
|
|
||||||
|
|
||||||
// Remove dragged item then insert at the adjusted index AFTER removal
|
|
||||||
const [moved] = sorted.splice(fromIndex, 1);
|
|
||||||
let insertIndex = fromIndex < desiredBeforeRemoval ? desiredBeforeRemoval - 1 : desiredBeforeRemoval;
|
|
||||||
insertIndex = Math.max(0, Math.min(insertIndex, sorted.length));
|
|
||||||
sorted.splice(insertIndex, 0, moved);
|
|
||||||
|
|
||||||
// Re-number sort_order for UI consistency (server will also normalize)
|
|
||||||
const expandedMap = new Map(chapters.value.map(ch => [ch.id, ch.expanded]));
|
|
||||||
const optimistic = sorted.map((ch, idx) => ({
|
|
||||||
...ch,
|
|
||||||
sort_order: idx + 1,
|
|
||||||
expanded: expandedMap.get(ch.id) ?? true
|
|
||||||
}));
|
|
||||||
|
|
||||||
chapters.value = optimistic;
|
|
||||||
|
|
||||||
// Update only the dragged chapter's sort_order; backend should shift others accordingly
|
|
||||||
await instructorService.reorderChapter(courseId.value, moved.id, insertIndex + 1);
|
|
||||||
|
|
||||||
$q.notify({ type: 'positive', message: 'เรียงลำดับใหม่สำเร็จ', position: 'top' });
|
|
||||||
fetchChapters();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert via re-fetch to guarantee consistency
|
console.error('Failed to reorder chapter:', error);
|
||||||
fetchChapters();
|
// Revert
|
||||||
|
const [item] = chapters.value.splice(newIndex, 1);
|
||||||
|
chapters.value.splice(oldIndex, 0, item);
|
||||||
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||||
} finally {
|
|
||||||
onDragEnd();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lesson Drag and Drop
|
const onLessonDragEnd = async (chapter: ChapterResponse & { expanded?: boolean }, event: { oldIndex: number; newIndex: number }) => {
|
||||||
const draggedLesson = ref<LessonResponse | null>(null);
|
const { oldIndex, newIndex } = event;
|
||||||
const dragOverLesson = ref<LessonResponse | null>(null);
|
if (oldIndex === newIndex) return;
|
||||||
const draggedLessonChapter = ref<ChapterResponse | null>(null);
|
|
||||||
|
|
||||||
const onLessonDragStart = (chapter: ChapterResponse, lesson: LessonResponse, event: DragEvent) => {
|
const lesson = chapter.lessons[newIndex];
|
||||||
draggedLesson.value = lesson;
|
if (!lesson) return;
|
||||||
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 {
|
try {
|
||||||
// Insert at target position - backend will shift other lessons
|
await instructorService.reorderLesson(courseId.value, chapter.id, lesson.id, newIndex + 1);
|
||||||
const targetSortOrder = targetLesson.sort_order;
|
|
||||||
|
|
||||||
await instructorService.reorderLesson(courseId.value, chapter.id, draggedLesson.value.id, targetSortOrder);
|
|
||||||
|
|
||||||
$q.notify({ type: 'positive', message: 'เรียงลำดับบทเรียนสำเร็จ', position: 'top' });
|
$q.notify({ type: 'positive', message: 'เรียงลำดับบทเรียนสำเร็จ', position: 'top' });
|
||||||
fetchChapters();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder lesson:', error);
|
||||||
|
// Revert
|
||||||
|
const [item] = chapter.lessons.splice(newIndex, 1);
|
||||||
|
chapter.lessons.splice(oldIndex, 0, item);
|
||||||
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||||
} finally {
|
|
||||||
onLessonDragEnd();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -474,7 +350,15 @@ const fetchChapters = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await instructorService.getChapters(courseId.value);
|
const data = await instructorService.getChapters(courseId.value);
|
||||||
chapters.value = data.map(ch => ({ ...ch, expanded: true }));
|
// Sort chapters by sort_order, and lessons within each chapter by sort_order
|
||||||
|
chapters.value = data
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map(ch => ({
|
||||||
|
...ch,
|
||||||
|
expanded: true,
|
||||||
|
lessons: ch.lessons.slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,32 @@
|
||||||
<div class="flex flex-col md:flex-row gap-8">
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
<!-- Avatar Section -->
|
<!-- Avatar Section -->
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<div class="w-32 h-32 bg-primary-100 rounded-full flex items-center justify-center text-6xl mb-4">
|
<div class="w-32 h-32 rounded-full flex items-center justify-center text-6xl mb-4 overflow-hidden bg-primary-100">
|
||||||
{{ profile.avatar }}
|
<img
|
||||||
|
v-if="profile.avatarUrl"
|
||||||
|
:key="profile.avatarUrl"
|
||||||
|
:src="profile.avatarUrl"
|
||||||
|
alt=""
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
@error="onAvatarError"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ profile.avatar }}</span>
|
||||||
</div>
|
</div>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="primary"
|
color="primary"
|
||||||
label="เปลี่ยนรูป"
|
label="เปลี่ยนรูป"
|
||||||
icon="photo_camera"
|
icon="photo_camera"
|
||||||
@click="handleAvatarUpload"
|
:loading="uploadingAvatar"
|
||||||
|
@click="triggerAvatarUpload"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-400 mt-2">ขนาดไม่เกิน 5MB</p>
|
||||||
|
<input
|
||||||
|
ref="avatarInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleAvatarUpload"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -283,6 +300,7 @@ const profile = ref({
|
||||||
role: '',
|
role: '',
|
||||||
roleName: '',
|
roleName: '',
|
||||||
avatar: '👨🏫',
|
avatar: '👨🏫',
|
||||||
|
avatarUrl: '' as string | null,
|
||||||
createdAt: ''
|
createdAt: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -330,12 +348,67 @@ const formatDate = (date: string) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAvatarUpload = () => {
|
// Avatar upload
|
||||||
$q.notify({
|
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||||
type: 'info',
|
const uploadingAvatar = ref(false);
|
||||||
message: 'ฟีเจอร์อัพโหลดรูปภาพจะพร้อมใช้งานเร็วๆ นี้',
|
|
||||||
position: 'top'
|
const triggerAvatarUpload = () => {
|
||||||
});
|
avatarInputRef.value?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAvatarError = () => {
|
||||||
|
// Fallback to emoji if image fails
|
||||||
|
profile.value.avatarUrl = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'กรุณาเลือกไฟล์รูปภาพเท่านั้น',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'ไฟล์มีขนาดใหญ่เกิน 5MB',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingAvatar.value = true;
|
||||||
|
try {
|
||||||
|
await userService.uploadAvatar(file);
|
||||||
|
|
||||||
|
// Re-fetch profile to get presigned URL from backend
|
||||||
|
await fetchProfile();
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'อัพโหลดรูปโปรไฟล์สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload avatar:', error);
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการอัพโหลดรูป',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
uploadingAvatar.value = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateProfile = async () => {
|
const handleUpdateProfile = async () => {
|
||||||
|
|
@ -432,7 +505,8 @@ const fetchProfile = async () => {
|
||||||
phone: data.profile.phone || '',
|
phone: data.profile.phone || '',
|
||||||
role: data.role.code,
|
role: data.role.code,
|
||||||
roleName: data.role.name.th,
|
roleName: data.role.name.th,
|
||||||
avatar: data.profile.avatar_url || '👨🏫',
|
avatar: '👨🏫',
|
||||||
|
avatarUrl: data.profile.avatar_url,
|
||||||
createdAt: data.created_at
|
createdAt: data.created_at
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
type="email"
|
type="email"
|
||||||
outlined
|
outlined
|
||||||
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
||||||
|
lazy-rules="ondemand"
|
||||||
|
hide-bottom-space
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="email" />
|
<q-icon name="email" />
|
||||||
|
|
@ -23,6 +25,8 @@
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
outlined
|
outlined
|
||||||
:rules="[val => !!val || 'กรุณากรอกรหัสผ่าน']"
|
:rules="[val => !!val || 'กรุณากรอกรหัสผ่าน']"
|
||||||
|
lazy-rules="ondemand"
|
||||||
|
hide-bottom-space
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="lock" />
|
<q-icon name="lock" />
|
||||||
|
|
@ -85,6 +89,8 @@
|
||||||
type="email"
|
type="email"
|
||||||
outlined
|
outlined
|
||||||
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="email" />
|
<q-icon name="email" />
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@
|
||||||
val => !!val || 'กรุณากรอก username',
|
val => !!val || 'กรุณากรอก username',
|
||||||
val => val.length >= 4 || 'อย่างน้อย 4 ตัวอักษร'
|
val => val.length >= 4 || 'อย่างน้อย 4 ตัวอักษร'
|
||||||
]"
|
]"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="person" />
|
<q-icon name="person" />
|
||||||
|
|
@ -40,6 +42,8 @@
|
||||||
val => !!val || 'กรุณากรอกอีเมล',
|
val => !!val || 'กรุณากรอกอีเมล',
|
||||||
val => /.+@.+\..+/.test(val) || 'รูปแบบอีเมลไม่ถูกต้อง'
|
val => /.+@.+\..+/.test(val) || 'รูปแบบอีเมลไม่ถูกต้อง'
|
||||||
]"
|
]"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="email" />
|
<q-icon name="email" />
|
||||||
|
|
@ -58,6 +62,8 @@
|
||||||
val => !!val || 'กรุณากรอกรหัสผ่าน',
|
val => !!val || 'กรุณากรอกรหัสผ่าน',
|
||||||
val => val.length >= 8 || 'อย่างน้อย 8 ตัวอักษร'
|
val => val.length >= 8 || 'อย่างน้อย 8 ตัวอักษร'
|
||||||
]"
|
]"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="lock" />
|
<q-icon name="lock" />
|
||||||
|
|
@ -80,6 +86,8 @@
|
||||||
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
||||||
val => val === form.password || 'รหัสผ่านไม่ตรงกัน'
|
val => val === form.password || 'รหัสผ่านไม่ตรงกัน'
|
||||||
]"
|
]"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="lock" />
|
<q-icon name="lock" />
|
||||||
|
|
@ -97,6 +105,8 @@
|
||||||
emit-value
|
emit-value
|
||||||
map-options
|
map-options
|
||||||
:rules="[val => !!val.th || 'กรุณาเลือกคำนำหน้า']"
|
:rules="[val => !!val.th || 'กรุณาเลือกคำนำหน้า']"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
|
|
@ -104,6 +114,8 @@
|
||||||
label="ชื่อจริง *"
|
label="ชื่อจริง *"
|
||||||
outlined
|
outlined
|
||||||
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
|
|
@ -111,6 +123,8 @@
|
||||||
label="นามสกุล *"
|
label="นามสกุล *"
|
||||||
outlined
|
outlined
|
||||||
:rules="[val => !!val || 'กรุณากรอกนามสกุล']"
|
:rules="[val => !!val || 'กรุณากรอกนามสกุล']"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -122,6 +136,8 @@
|
||||||
outlined
|
outlined
|
||||||
mask="###-###-####"
|
mask="###-###-####"
|
||||||
:rules="[val => !!val || 'กรุณากรอกเบอร์โทร']"
|
:rules="[val => !!val || 'กรุณากรอกเบอร์โทร']"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="phone" />
|
<q-icon name="phone" />
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@
|
||||||
val => !!val || 'กรุณากรอกรหัสผ่าน',
|
val => !!val || 'กรุณากรอกรหัสผ่าน',
|
||||||
val => val.length >= 8 || 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร'
|
val => val.length >= 8 || 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร'
|
||||||
]"
|
]"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="lock" />
|
<q-icon name="lock" />
|
||||||
|
|
@ -52,6 +54,8 @@
|
||||||
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
||||||
val => val === password || 'รหัสผ่านไม่ตรงกัน'
|
val => val === password || 'รหัสผ่านไม่ตรงกัน'
|
||||||
]"
|
]"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="lock" />
|
<q-icon name="lock" />
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,127 @@ export interface UsersListResponse {
|
||||||
data: AdminUserResponse[];
|
data: AdminUserResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pending Course interfaces
|
||||||
|
export interface PendingCourseUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingCourseInstructor {
|
||||||
|
user_id: number;
|
||||||
|
is_primary: boolean;
|
||||||
|
user: PendingCourseUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingCourseSubmission {
|
||||||
|
id: number;
|
||||||
|
submitted_by: number;
|
||||||
|
created_at: string;
|
||||||
|
submitter: PendingCourseUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingCourse {
|
||||||
|
id: number;
|
||||||
|
title: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
description: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
created_by: number;
|
||||||
|
creator: PendingCourseUser;
|
||||||
|
instructors: PendingCourseInstructor[];
|
||||||
|
chapters_count: number;
|
||||||
|
lessons_count: number;
|
||||||
|
latest_submission: PendingCourseSubmission | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingCoursesListResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: PendingCourse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Course Detail interfaces for admin review
|
||||||
|
export interface CourseCategory {
|
||||||
|
id: number;
|
||||||
|
name: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseLesson {
|
||||||
|
id: number;
|
||||||
|
title: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
type: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_published: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseChapter {
|
||||||
|
id: number;
|
||||||
|
title: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
sort_order: number;
|
||||||
|
is_published: boolean;
|
||||||
|
lessons: CourseLesson[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalHistory {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
comment: string | null;
|
||||||
|
created_at: string;
|
||||||
|
submitter: PendingCourseUser;
|
||||||
|
reviewer: PendingCourseUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseDetailForReview {
|
||||||
|
id: number;
|
||||||
|
title: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
description: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
price: number;
|
||||||
|
is_free: boolean;
|
||||||
|
have_certificate: boolean;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
category: CourseCategory;
|
||||||
|
creator: PendingCourseUser;
|
||||||
|
instructors: PendingCourseInstructor[];
|
||||||
|
chapters: CourseChapter[];
|
||||||
|
approval_history: ApprovalHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseDetailForReviewResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: CourseDetailForReview;
|
||||||
|
}
|
||||||
|
|
||||||
// Mock data for development
|
// Mock data for development
|
||||||
const MOCK_USERS: AdminUserResponse[] = [
|
const MOCK_USERS: AdminUserResponse[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -83,6 +204,121 @@ const MOCK_USERS: AdminUserResponse[] = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Mock pending courses data
|
||||||
|
const MOCK_PENDING_COURSES: PendingCourse[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: { th: 'พื้นฐาน JavaScript', en: 'JavaScript Fundamentals' },
|
||||||
|
slug: 'javascript-fundamentals',
|
||||||
|
description: { th: 'เรียนรู้ JavaScript ตั้งแต่เริ่มต้น', en: 'Learn JavaScript from scratch' },
|
||||||
|
thumbnail_url: null,
|
||||||
|
status: 'PENDING',
|
||||||
|
created_at: '2024-02-01T00:00:00Z',
|
||||||
|
updated_at: '2024-02-10T00:00:00Z',
|
||||||
|
created_by: 2,
|
||||||
|
creator: { id: 2, email: 'instructor@elearning.local', username: 'instructor' },
|
||||||
|
instructors: [
|
||||||
|
{
|
||||||
|
user_id: 2,
|
||||||
|
is_primary: true,
|
||||||
|
user: { id: 2, email: 'instructor@elearning.local', username: 'instructor' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chapters_count: 5,
|
||||||
|
lessons_count: 15,
|
||||||
|
latest_submission: {
|
||||||
|
id: 1,
|
||||||
|
submitted_by: 2,
|
||||||
|
created_at: '2024-02-10T00:00:00Z',
|
||||||
|
submitter: { id: 2, email: 'instructor@elearning.local', username: 'instructor' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: { th: 'React สำหรับผู้เริ่มต้น', en: 'React for Beginners' },
|
||||||
|
slug: 'react-for-beginners',
|
||||||
|
description: { th: 'เรียนรู้ React ตั้งแต่พื้นฐาน', en: 'Learn React from basics' },
|
||||||
|
thumbnail_url: null,
|
||||||
|
status: 'PENDING',
|
||||||
|
created_at: '2024-02-05T00:00:00Z',
|
||||||
|
updated_at: '2024-02-12T00:00:00Z',
|
||||||
|
created_by: 2,
|
||||||
|
creator: { id: 2, email: 'instructor@elearning.local', username: 'instructor' },
|
||||||
|
instructors: [
|
||||||
|
{
|
||||||
|
user_id: 2,
|
||||||
|
is_primary: true,
|
||||||
|
user: { id: 2, email: 'instructor@elearning.local', username: 'instructor' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chapters_count: 8,
|
||||||
|
lessons_count: 24,
|
||||||
|
latest_submission: {
|
||||||
|
id: 2,
|
||||||
|
submitted_by: 2,
|
||||||
|
created_at: '2024-02-12T00:00:00Z',
|
||||||
|
submitter: { id: 2, email: 'instructor@elearning.local', username: 'instructor' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock course detail for review
|
||||||
|
const MOCK_COURSE_DETAIL: CourseDetailForReview = {
|
||||||
|
id: 1,
|
||||||
|
title: { th: 'พื้นฐาน JavaScript', en: 'JavaScript Fundamentals' },
|
||||||
|
slug: 'javascript-fundamentals',
|
||||||
|
description: { th: 'เรียนรู้ JavaScript ตั้งแต่เริ่มต้น รวมถึง ES6+ features และ best practices', en: 'Learn JavaScript from scratch including ES6+ features and best practices' },
|
||||||
|
thumbnail_url: null,
|
||||||
|
price: 990,
|
||||||
|
is_free: false,
|
||||||
|
have_certificate: true,
|
||||||
|
status: 'PENDING',
|
||||||
|
created_at: '2024-02-01T00:00:00Z',
|
||||||
|
updated_at: '2024-02-10T00:00:00Z',
|
||||||
|
category: { id: 1, name: { th: 'การพัฒนาเว็บไซต์', en: 'Web Development' } },
|
||||||
|
creator: { id: 2, email: 'instructor@elearning.local', username: 'instructor' },
|
||||||
|
instructors: [
|
||||||
|
{
|
||||||
|
user_id: 2,
|
||||||
|
is_primary: true,
|
||||||
|
user: { id: 2, email: 'instructor@elearning.local', username: 'instructor' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: { th: 'แนะนำ JavaScript', en: 'Introduction to JavaScript' },
|
||||||
|
sort_order: 1,
|
||||||
|
is_published: true,
|
||||||
|
lessons: [
|
||||||
|
{ id: 1, title: { th: 'JavaScript คืออะไร', en: 'What is JavaScript' }, type: 'VIDEO', sort_order: 1, is_published: true },
|
||||||
|
{ id: 2, title: { th: 'ติดตั้ง Development Environment', en: 'Setup Development Environment' }, type: 'VIDEO', sort_order: 2, is_published: true },
|
||||||
|
{ id: 3, title: { th: 'แบบทดสอบบทที่ 1', en: 'Chapter 1 Quiz' }, type: 'QUIZ', sort_order: 3, is_published: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: { th: 'Variables และ Data Types', en: 'Variables and Data Types' },
|
||||||
|
sort_order: 2,
|
||||||
|
is_published: true,
|
||||||
|
lessons: [
|
||||||
|
{ id: 4, title: { th: 'var, let และ const', en: 'var, let and const' }, type: 'VIDEO', sort_order: 1, is_published: true },
|
||||||
|
{ id: 5, title: { th: 'Primitive Data Types', en: 'Primitive Data Types' }, type: 'VIDEO', sort_order: 2, is_published: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
approval_history: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
action: 'SUBMITTED',
|
||||||
|
comment: null,
|
||||||
|
created_at: '2024-02-10T10:00:00Z',
|
||||||
|
submitter: { id: 2, email: 'instructor@elearning.local', username: 'instructor' },
|
||||||
|
reviewer: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to get auth token from cookie or localStorage
|
// Helper function to get auth token from cookie or localStorage
|
||||||
const getAuthToken = (): string => {
|
const getAuthToken = (): string => {
|
||||||
const tokenCookie = useCookie('token');
|
const tokenCookie = useCookie('token');
|
||||||
|
|
@ -174,6 +410,87 @@ export const adminService = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ============ Pending Courses ============
|
||||||
|
async getPendingCourses(): Promise<PendingCourse[]> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_PENDING_COURSES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<PendingCoursesListResponse>('/api/admin/courses/pending', {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCourseForReview(courseId: number): Promise<CourseDetailForReview> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_COURSE_DETAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<CourseDetailForReviewResponse>(`/api/admin/courses/${courseId}`, {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveCourse(courseId: number, comment?: string): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch(`/api/admin/courses/${courseId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: { comment: comment || '' }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async rejectCourse(courseId: number, comment: string): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch(`/api/admin/courses/${courseId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: { comment }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// ============ Categories ============
|
// ============ Categories ============
|
||||||
async getCategories(): Promise<CategoryResponse[]> {
|
async getCategories(): Promise<CategoryResponse[]> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,48 @@ export const instructorService = {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reorderQuestion(courseId: number, chapterId: number, lessonId: number, questionId: 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/${lessonId}/questions/${questionId}/reorder`,
|
||||||
|
{ method: 'PUT', body: { sort_order: sortOrder } }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quiz Settings
|
||||||
|
async updateQuizSettings(courseId: number, chapterId: number, lessonId: number, data: UpdateQuizSettingsRequest): Promise<QuizResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
lesson_id: lessonId,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
passing_score: data.passing_score,
|
||||||
|
time_limit: data.time_limit,
|
||||||
|
shuffle_questions: data.shuffle_questions,
|
||||||
|
shuffle_choices: data.shuffle_choices,
|
||||||
|
show_answers_after_completion: data.show_answers_after_completion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authRequest<{ code: number; data: QuizResponse }>(
|
||||||
|
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/quiz`,
|
||||||
|
{ method: 'PUT', body: data }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Video Upload
|
// Video Upload
|
||||||
async uploadLessonVideo(courseId: number, chapterId: number, lessonId: number, video: File, attachments?: File[]): Promise<LessonResponse> {
|
async uploadLessonVideo(courseId: number, chapterId: number, lessonId: number, video: File, attachments?: File[]): Promise<LessonResponse> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
|
@ -567,7 +609,7 @@ export const instructorService = {
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
formData.append('attachments', file);
|
formData.append('attachment', file);
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await authRequest<{ code: number; data: LessonResponse }>(
|
const response = await authRequest<{ code: number; data: LessonResponse }>(
|
||||||
|
|
@ -590,6 +632,130 @@ export const instructorService = {
|
||||||
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/attachments/${attachmentId}`,
|
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/attachments/${attachmentId}`,
|
||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Announcements
|
||||||
|
async getAnnouncements(courseId: number): Promise<AnnouncementResponse[]> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_ANNOUNCEMENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authRequest<AnnouncementsListResponse>(
|
||||||
|
`/api/instructors/courses/${courseId}/announcements`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAnnouncement(courseId: number, data: CreateAnnouncementRequest, files?: File[]): Promise<AnnouncementResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return {
|
||||||
|
id: Date.now(),
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
status: data.status || 'DRAFT',
|
||||||
|
is_pinned: data.is_pinned || false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
attachments: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('data', JSON.stringify(data));
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
files.forEach(file => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authRequest<{ code: number; data: AnnouncementResponse }>(
|
||||||
|
`/api/instructors/courses/${courseId}/announcements`,
|
||||||
|
{ method: 'POST', body: formData }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAnnouncement(courseId: number, announcementId: number, data: CreateAnnouncementRequest): Promise<AnnouncementResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return {
|
||||||
|
id: announcementId,
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
status: data.status || 'DRAFT',
|
||||||
|
is_pinned: data.is_pinned || false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
attachments: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authRequest<{ code: number; data: AnnouncementResponse }>(
|
||||||
|
`/api/instructors/courses/${courseId}/announcements/${announcementId}`,
|
||||||
|
{ method: 'PUT', body: data }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAnnouncement(courseId: number, announcementId: 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}/announcements/${announcementId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadAnnouncementAttachment(courseId: number, announcementId: number, file: File): Promise<AnnouncementResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_ANNOUNCEMENTS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await authRequest<{ code: number; data: AnnouncementResponse }>(
|
||||||
|
`/api/instructors/courses/${courseId}/announcements/${announcementId}/attachments`,
|
||||||
|
{ method: 'POST', body: formData }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAnnouncementAttachment(courseId: number, announcementId: number, attachmentId: 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}/announcements/${announcementId}/attachments/${attachmentId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -683,6 +849,16 @@ export interface QuizResponse {
|
||||||
questions?: QuizQuestionResponse[];
|
questions?: QuizQuestionResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateQuizSettingsRequest {
|
||||||
|
title: { th: string; en: string };
|
||||||
|
description: { th: string; en: string };
|
||||||
|
passing_score: number;
|
||||||
|
time_limit: number;
|
||||||
|
shuffle_questions: boolean;
|
||||||
|
shuffle_choices: boolean;
|
||||||
|
show_answers_after_completion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateChapterRequest {
|
export interface CreateChapterRequest {
|
||||||
title: { th: string; en: string };
|
title: { th: string; en: string };
|
||||||
description: { th: string; en: string };
|
description: { th: string; en: string };
|
||||||
|
|
@ -732,6 +908,84 @@ export interface LessonDetailResponse extends LessonResponse {
|
||||||
attachments: AttachmentResponse[];
|
attachments: AttachmentResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announcement interfaces
|
||||||
|
export interface AnnouncementAttachment {
|
||||||
|
id: number;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementResponse {
|
||||||
|
id: number;
|
||||||
|
title: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
is_pinned: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
attachments: AnnouncementAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementsListResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AnnouncementResponse[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAnnouncementRequest {
|
||||||
|
title: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
status?: 'DRAFT' | 'PUBLISHED';
|
||||||
|
is_pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock announcements
|
||||||
|
const MOCK_ANNOUNCEMENTS: AnnouncementResponse[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: { th: 'ยินดีต้อนรับสู่คอร์ส', en: 'Welcome to the Course' },
|
||||||
|
content: {
|
||||||
|
th: 'ยินดีต้อนรับทุกคนสู่คอร์ส JavaScript Fundamentals! เราจะเริ่มเรียนในสัปดาห์หน้า',
|
||||||
|
en: 'Welcome everyone to JavaScript Fundamentals! We will start next week'
|
||||||
|
},
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
is_pinned: true,
|
||||||
|
created_at: '2024-01-15T10:00:00Z',
|
||||||
|
updated_at: '2024-01-15T10:00:00Z',
|
||||||
|
attachments: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: { th: 'อัพเดทตารางเรียน', en: 'Schedule Update' },
|
||||||
|
content: {
|
||||||
|
th: 'มีการเปลี่ยนแปลงตารางเรียนสำหรับบทที่ 3',
|
||||||
|
en: 'There is a schedule change for Chapter 3'
|
||||||
|
},
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
is_pinned: false,
|
||||||
|
created_at: '2024-01-20T14:00:00Z',
|
||||||
|
updated_at: '2024-01-20T14:00:00Z',
|
||||||
|
attachments: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// Mock course detail
|
// Mock course detail
|
||||||
const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
||||||
...MOCK_COURSES[0],
|
...MOCK_COURSES[0],
|
||||||
|
|
|
||||||
|
|
@ -178,5 +178,31 @@ export const userService = {
|
||||||
newPassword
|
newPassword
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadAvatar(file: File): Promise<{ avatar_url: string; id: number }> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
// Return mock URL
|
||||||
|
return { avatar_url: URL.createObjectURL(file), id: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await $fetch<{ code: number; message: string; data: { avatar_url: string; id: number } }>('/api/user/upload-avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue