1555 lines
56 KiB
Vue
1555 lines
56 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="flex justify-center py-20">
|
|
<q-spinner-dots size="50px" color="primary" />
|
|
</div>
|
|
|
|
<template v-else-if="course">
|
|
<!-- Course Header -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
<div class="flex flex-col md:flex-row gap-6">
|
|
<!-- Thumbnail -->
|
|
<!-- Thumbnail -->
|
|
<div
|
|
class="w-full md:w-48 h-32 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 relative group cursor-pointer overflow-hidden border border-gray-200"
|
|
@click="triggerThumbnailUpload"
|
|
>
|
|
<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="flex flex-col items-center">
|
|
<q-icon name="image" size="30px" color="grey-5" />
|
|
<span class="text-grey-6 text-xs mt-1">อัพโหลดรูป</span>
|
|
</div>
|
|
|
|
<!-- Overlay -->
|
|
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center">
|
|
<q-icon name="photo_camera" color="white" size="24px" />
|
|
<span class="text-white text-xs mt-1">เปลี่ยนรูป</span>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="uploadingThumbnail" class="absolute inset-0 bg-white/80 flex items-center justify-center">
|
|
<q-spinner color="primary" size="2em" />
|
|
</div>
|
|
|
|
<!-- Hidden Input -->
|
|
<input
|
|
ref="thumbnailInputRef"
|
|
type="file"
|
|
accept="image/*"
|
|
class="hidden"
|
|
@click.stop
|
|
@change="handleThumbnailUpload"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="flex-1">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900">{{ course.title.th }}</h1>
|
|
<p class="text-gray-600 mt-1">{{ course.description.th }}</p>
|
|
</div>
|
|
|
|
<!-- Status Badges -->
|
|
<div class="flex gap-2">
|
|
<q-badge v-if="course.is_free" color="purple">ฟรี</q-badge>
|
|
<q-badge v-else color="purple">เสียเงิน</q-badge>
|
|
<q-badge :color="getStatusColor(course.status)">
|
|
{{ getStatusLabel(course.status) }}
|
|
</q-badge>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="flex items-center gap-6 mt-4 text-gray-600">
|
|
<div class="flex items-center gap-1">
|
|
<q-icon name="menu_book" size="20px" />
|
|
<span>{{ totalLessons }} บทเรียน</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<q-icon name="people" size="20px" />
|
|
<span>0 ผู้เรียน</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex flex-col gap-2">
|
|
<q-btn
|
|
outline
|
|
color="primary"
|
|
label="แก้ไข"
|
|
icon="edit"
|
|
@click="navigateTo(`/instructor/courses/${course.id}/edit`)"
|
|
/>
|
|
<q-btn
|
|
v-if="course.status === 'DRAFT'"
|
|
color="primary"
|
|
label="ขออนุมัติหลักสูตร"
|
|
@click="requestApproval"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<q-tabs
|
|
v-model="activeTab"
|
|
class="bg-white rounded-t-xl shadow-sm text-primary-600"
|
|
active-color="primary"
|
|
indicator-color="primary"
|
|
align="left"
|
|
>
|
|
<q-tab name="structure" icon="list" label="โครงสร้าง" />
|
|
<q-tab name="students" icon="people" label="ผู้เรียน" />
|
|
<q-tab name="instructors" icon="manage_accounts" label="ผู้สอน" />
|
|
<q-tab name="quiz" icon="quiz" label="ผลการทดสอบ" />
|
|
<q-tab name="announcements" icon="campaign" label="ประกาศ" />
|
|
</q-tabs>
|
|
|
|
<!-- Tab Panels -->
|
|
<q-tab-panels v-model="activeTab" class="bg-white rounded-b-xl shadow-sm">
|
|
<!-- Structure Tab -->
|
|
<q-tab-panel name="structure" class="p-6">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-semibold text-gray-900">โครงสร้างบทเรียน</h2>
|
|
<q-btn
|
|
color="primary"
|
|
label="จัดการโครงสร้าง"
|
|
@click="navigateTo(`/instructor/courses/${course.id}/structure`)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Chapters -->
|
|
<div v-if="course.chapters.length === 0" class="text-center py-10 text-gray-500">
|
|
ยังไม่มีบทเรียน
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<q-card
|
|
v-for="chapter in sortedChapters"
|
|
:key="chapter.id"
|
|
flat
|
|
bordered
|
|
class="rounded-lg"
|
|
>
|
|
<q-card-section>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="font-semibold text-gray-900">
|
|
Chapter {{ chapter.sort_order }}: {{ chapter.title.th }}
|
|
</div>
|
|
<div class="text-sm text-gray-500 mt-1">
|
|
{{ chapter.lessons.length }} บทเรียน · {{ getChapterDuration(chapter) }} นาที
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<!-- Lessons -->
|
|
<q-list separator class="border-t">
|
|
<q-item
|
|
v-for="lesson in getSortedLessons(chapter)"
|
|
:key="lesson.id"
|
|
class="py-3"
|
|
>
|
|
<q-item-section avatar>
|
|
<q-icon
|
|
:name="getLessonIcon(lesson.type)"
|
|
:color="getLessonIconColor(lesson.type)"
|
|
/>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>
|
|
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<span class="text-sm text-gray-500">{{ lesson.duration_minutes }} นาที</span>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-card>
|
|
</div>
|
|
</q-tab-panel>
|
|
|
|
<!-- Students Tab -->
|
|
<q-tab-panel name="students" class="p-6">
|
|
<!-- Stats Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
|
<q-card flat bordered class="rounded-lg">
|
|
<q-card-section class="text-center py-6">
|
|
<div class="text-4xl font-bold text-primary">{{ studentsPagination.total }}</div>
|
|
<div class="text-gray-500 mt-1">ผู้เรียนทั้งหมด</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
<q-card flat bordered class="rounded-lg">
|
|
<q-card-section class="text-center py-6">
|
|
<div class="text-4xl font-bold text-green-600">{{ completedStudentsCount }}</div>
|
|
<div class="text-gray-500 mt-1">จบหลักสูตร</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
|
|
<!-- Search and Filter -->
|
|
<q-card flat bordered class="rounded-lg mb-6">
|
|
<q-card-section>
|
|
<div class="flex flex-col md:flex-row gap-4">
|
|
<q-input
|
|
v-model="studentSearch"
|
|
outlined
|
|
dense
|
|
placeholder="ค้นหาผู้เรียน..."
|
|
class="flex-1"
|
|
debounce="300"
|
|
@update:model-value="handleStudentSearch"
|
|
>
|
|
<template v-slot:prepend>
|
|
<q-icon name="search" />
|
|
</template>
|
|
<template v-slot:append v-if="studentSearch">
|
|
<q-icon name="close" class="cursor-pointer" @click="studentSearch = ''; handleStudentSearch()" />
|
|
</template>
|
|
</q-input>
|
|
<q-select
|
|
v-model="studentStatusFilter"
|
|
outlined
|
|
dense
|
|
:options="statusFilterOptions"
|
|
option-value="value"
|
|
option-label="label"
|
|
emit-value
|
|
map-options
|
|
class="w-full md:w-40"
|
|
@update:model-value="handleStudentSearch"
|
|
/>
|
|
<q-select
|
|
v-model="studentsPagination.limit"
|
|
outlined
|
|
dense
|
|
:options="limitOptions"
|
|
option-value="value"
|
|
option-label="label"
|
|
emit-value
|
|
map-options
|
|
class="w-full md:w-32"
|
|
@update:model-value="handleLimitChange"
|
|
/>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<div v-if="loadingStudents" class="flex justify-center py-10">
|
|
<q-spinner color="primary" size="40px" />
|
|
</div>
|
|
|
|
<div v-else-if="filteredStudents.length === 0" class="text-center py-10 text-gray-500">
|
|
<q-icon name="people" size="60px" color="grey-4" class="mb-4" />
|
|
<p>{{ studentSearch || studentStatusFilter !== 'all' ? 'ไม่พบผู้เรียนที่ค้นหา' : 'ยังไม่มีผู้เรียนในหลักสูตรนี้' }}</p>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- Students Table -->
|
|
<q-card flat bordered class="rounded-lg">
|
|
<q-list separator>
|
|
<q-item
|
|
v-for="student in filteredStudents"
|
|
:key="student.user_id"
|
|
class="py-4 cursor-pointer hover:bg-gray-50 transition-colors"
|
|
clickable
|
|
@click="openStudentDetail(student.user_id)"
|
|
>
|
|
<q-item-section avatar>
|
|
<q-avatar size="50px" color="primary" text-color="white">
|
|
<img v-if="student.avatar_url" :src="student.avatar_url" />
|
|
<span v-else>{{ student.username.charAt(0).toUpperCase() }}</span>
|
|
</q-avatar>
|
|
</q-item-section>
|
|
|
|
<q-item-section>
|
|
<q-item-label class="text-base font-medium">
|
|
{{ student.first_name }} {{ student.last_name }}
|
|
</q-item-label>
|
|
<q-item-label caption>{{ student.email }}</q-item-label>
|
|
</q-item-section>
|
|
|
|
<q-item-section>
|
|
<q-item-label caption class="mb-1">ความคืบหน้า</q-item-label>
|
|
<div class="flex items-center gap-2">
|
|
<q-linear-progress
|
|
:value="student.progress_percentage / 100"
|
|
color="primary"
|
|
track-color="grey-3"
|
|
class="flex-1"
|
|
rounded
|
|
size="8px"
|
|
/>
|
|
<span class="text-sm text-gray-600 w-12 text-right">{{ student.progress_percentage }}%</span>
|
|
</div>
|
|
</q-item-section>
|
|
|
|
<q-item-section side>
|
|
<q-badge :color="getStudentStatusColor(student.status)">
|
|
{{ getStudentStatusLabel(student.status) }}
|
|
</q-badge>
|
|
<q-item-label caption class="mt-1">
|
|
ลงทะเบียน {{ formatEnrollDate(student.enrolled_at) }}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-card>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="studentsPagination.total > studentsPagination.limit" class="flex justify-center mt-4">
|
|
<q-pagination
|
|
v-model="studentsPagination.page"
|
|
:max="Math.ceil(studentsPagination.total / studentsPagination.limit)"
|
|
:max-pages="6"
|
|
direction-links
|
|
boundary-links
|
|
@update:model-value="fetchStudents"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</q-tab-panel>
|
|
|
|
|
|
|
|
<!-- Instructors Tab -->
|
|
<q-tab-panel name="instructors" class="p-6">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-xl font-semibold text-gray-900">ผู้สอนในรายวิชา</h2>
|
|
<q-btn v-if="isPrimaryInstructor" color="primary" icon="person_add" label="เพิ่มผู้สอน" @click="showAddInstructorDialog = true" />
|
|
</div>
|
|
|
|
<div v-if="loadingInstructors" class="flex justify-center py-10">
|
|
<q-spinner color="primary" size="40px" />
|
|
</div>
|
|
|
|
<div v-else-if="instructors.length === 0" class="text-center py-10 text-gray-500">
|
|
<q-icon name="group_off" size="60px" color="grey-4" class="mb-4" />
|
|
<p>ยังไม่มีข้อมูลผู้สอน</p>
|
|
</div>
|
|
|
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<q-card v-for="instructor in instructors" :key="instructor.id" flat bordered class="rounded-lg">
|
|
<q-item>
|
|
<q-item-section avatar>
|
|
<q-avatar size="50px" color="primary" text-color="white">
|
|
<img v-if="instructor.user.avatar_url" :src="instructor.user.avatar_url">
|
|
<span v-else>{{ instructor.user.username.charAt(0).toUpperCase() }}</span>
|
|
</q-avatar>
|
|
</q-item-section>
|
|
|
|
<q-item-section>
|
|
<q-item-label class="text-base font-medium flex items-center gap-2">
|
|
{{ instructor.user.username }}
|
|
<q-badge v-if="instructor.is_primary" color="primary" label="หัวหน้าผู้สอน" />
|
|
</q-item-label>
|
|
<q-item-label caption>{{ instructor.user.email }}</q-item-label>
|
|
</q-item-section>
|
|
|
|
<q-item-section side v-if="isPrimaryInstructor">
|
|
<q-btn flat dense round icon="more_vert">
|
|
<q-menu>
|
|
<q-item v-if="!instructor.is_primary" clickable v-close-popup @click="setPrimaryInstructor(instructor.user_id)">
|
|
<q-item-section avatar>
|
|
<q-icon name="verified_user" color="primary" />
|
|
</q-item-section>
|
|
<q-item-section>ตั้งเป็นหัวหน้าผู้สอน</q-item-section>
|
|
</q-item>
|
|
<q-item clickable v-close-popup @click="removeInstructor(instructor.user_id)" class="text-red">
|
|
<q-item-section avatar>
|
|
<q-icon name="person_remove" color="red" />
|
|
</q-item-section>
|
|
<q-item-section>ลบผู้สอน</q-item-section>
|
|
</q-item>
|
|
</q-menu>
|
|
</q-btn>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-card>
|
|
</div>
|
|
</q-tab-panel>
|
|
|
|
<!-- Quiz Results Tab -->
|
|
<q-tab-panel name="quiz" class="p-6">
|
|
<div class="text-center py-10 text-gray-500">
|
|
<q-icon name="quiz" size="60px" color="grey-4" class="mb-4" />
|
|
<p>ยังไม่มีผลการทดสอบ</p>
|
|
</div>
|
|
</q-tab-panel>
|
|
|
|
<!-- Announcements Tab -->
|
|
<q-tab-panel name="announcements" class="p-6">
|
|
<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" />
|
|
<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>
|
|
</q-tab-panel>
|
|
</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 || 'กรุณากรอกหัวข้อ']"
|
|
lazy-rules="ondemand"
|
|
hide-bottom-space
|
|
/>
|
|
<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 || 'กรุณากรอกเนื้อหา']"
|
|
lazy-rules="ondemand"
|
|
hide-bottom-space
|
|
/>
|
|
<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 && 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>
|
|
<!-- Add Instructor Dialog -->
|
|
<q-dialog v-model="showAddInstructorDialog">
|
|
<q-card style="min-width: 400px">
|
|
<q-card-section>
|
|
<div class="text-h6">เพิ่มผู้สอน</div>
|
|
</q-card-section>
|
|
|
|
<q-card-section>
|
|
<q-select
|
|
v-model="selectedUser"
|
|
:options="searchResults"
|
|
option-value="id"
|
|
option-label="email"
|
|
label="ค้นหาผู้สอน (Email หรือ Username)"
|
|
hint="พิมพ์อย่างน้อย 2 ตัวอักษรเพื่อค้นหา"
|
|
use-input
|
|
filled
|
|
@filter="filterUsers"
|
|
:loading="loadingSearch"
|
|
>
|
|
<template v-slot:option="scope">
|
|
<q-item v-bind="scope.itemProps">
|
|
<q-item-section avatar>
|
|
<q-avatar>
|
|
<img v-if="scope.opt.profile?.avatar_url" :src="scope.opt.profile.avatar_url">
|
|
<span v-else>{{ scope.opt.username.charAt(0).toUpperCase() }}</span>
|
|
</q-avatar>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ scope.opt.username }}</q-item-label>
|
|
<q-item-label caption>{{ scope.opt.email }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</template>
|
|
<template v-slot:no-option>
|
|
<q-item>
|
|
<q-item-section class="text-grey">
|
|
ไม่พบผู้ใช้
|
|
</q-item-section>
|
|
</q-item>
|
|
</template>
|
|
</q-select>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right">
|
|
<q-btn flat label="ยกเลิก" color="primary" v-close-popup />
|
|
<q-btn flat label="เพิ่ม" color="primary" @click="addInstructor" :disable="!selectedUser" :loading="addingInstructor" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Student Progress Detail Modal -->
|
|
<q-dialog v-model="showStudentDetailModal" maximized transition-show="slide-up" transition-hide="slide-down">
|
|
<q-card class="bg-gray-50">
|
|
<!-- Header -->
|
|
<q-card-section class="bg-white shadow-sm">
|
|
<div class="flex justify-between items-center">
|
|
<div class="flex items-center gap-4" v-if="studentDetail">
|
|
<q-avatar size="60px" color="primary" text-color="white">
|
|
<img v-if="studentDetail.student.avatar_url" :src="studentDetail.student.avatar_url" />
|
|
<span v-else>{{ studentDetail.student.username.charAt(0).toUpperCase() }}</span>
|
|
</q-avatar>
|
|
<div>
|
|
<h2 class="text-xl font-semibold">{{ studentDetail.student.first_name }} {{ studentDetail.student.last_name }}</h2>
|
|
<p class="text-gray-500">{{ studentDetail.student.email }}</p>
|
|
</div>
|
|
</div>
|
|
<q-btn icon="close" flat round dense v-close-popup />
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loadingStudentDetail" class="flex justify-center items-center py-20">
|
|
<q-spinner color="primary" size="50px" />
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<q-card-section v-else-if="studentDetail" class="q-pa-lg">
|
|
<!-- Progress Summary Bar -->
|
|
<q-card flat bordered class="mb-6 rounded-lg bg-white">
|
|
<q-card-section>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-gray-600">ความคืบหน้าทั้งหมด</span>
|
|
<div class="flex items-center gap-3">
|
|
<q-badge :color="getStudentStatusColor(studentDetail.enrollment.status)">
|
|
{{ getStudentStatusLabel(studentDetail.enrollment.status) }}
|
|
</q-badge>
|
|
<span class="font-semibold text-lg">{{ studentDetail.enrollment.progress_percentage }}%</span>
|
|
</div>
|
|
</div>
|
|
<q-linear-progress
|
|
:value="studentDetail.enrollment.progress_percentage / 100"
|
|
color="primary"
|
|
track-color="grey-3"
|
|
rounded
|
|
size="12px"
|
|
/>
|
|
<div class="flex justify-between mt-2 text-sm text-gray-500">
|
|
<span>เรียนจบ {{ studentDetail.total_completed_lessons }} / {{ studentDetail.total_lessons }} บทเรียน</span>
|
|
<span>ลงทะเบียน {{ formatEnrollDate(studentDetail.enrollment.enrolled_at) }}</span>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Chapters & Lessons -->
|
|
<div class="space-y-4">
|
|
<q-expansion-item
|
|
v-for="chapter in studentDetail.chapters"
|
|
:key="chapter.chapter_id"
|
|
group="chapters"
|
|
class="bg-white rounded-lg shadow-sm overflow-hidden"
|
|
expand-separator
|
|
dense
|
|
>
|
|
<template v-slot:header>
|
|
<q-item-section avatar>
|
|
<q-circular-progress
|
|
:value="chapter.total_lessons > 0 ? (chapter.completed_lessons / chapter.total_lessons) * 100 : 0"
|
|
size="45px"
|
|
:thickness="0.15"
|
|
color="primary"
|
|
track-color="grey-3"
|
|
show-value
|
|
class="text-primary"
|
|
>
|
|
<span class="text-xs">{{ chapter.completed_lessons }}/{{ chapter.total_lessons }}</span>
|
|
</q-circular-progress>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label class="font-medium">{{ chapter.chapter_title.th }}</q-item-label>
|
|
<q-item-label caption>{{ chapter.completed_lessons }} จาก {{ chapter.total_lessons }} บทเรียน</q-item-label>
|
|
</q-item-section>
|
|
</template>
|
|
|
|
<!-- Lessons List -->
|
|
<q-list separator class="bg-gray-50">
|
|
<q-item v-for="lesson in chapter.lessons" :key="lesson.lesson_id" class="py-3">
|
|
<q-item-section avatar>
|
|
<q-icon
|
|
:name="lesson.is_completed ? 'check_circle' : 'radio_button_unchecked'"
|
|
:color="lesson.is_completed ? 'green' : 'grey'"
|
|
size="24px"
|
|
/>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ lesson.lesson_title.th }}</q-item-label>
|
|
<q-item-label caption class="flex items-center gap-2">
|
|
<q-icon :name="getLessonTypeIcon(lesson.lesson_type)" size="14px" />
|
|
{{ getLessonTypeLabel(lesson.lesson_type) }}
|
|
<template v-if="lesson.lesson_type === 'VIDEO' && lesson.video_duration_seconds > 0">
|
|
• {{ formatVideoTime(lesson.video_progress_seconds) }} / {{ formatVideoTime(lesson.video_duration_seconds) }}
|
|
({{ lesson.video_progress_percentage }}%)
|
|
</template>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side v-if="lesson.is_completed">
|
|
<q-item-label caption class="text-green-600">
|
|
เสร็จ {{ formatCompletedDate(lesson.completed_at) }}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side v-else-if="lesson.lesson_type === 'VIDEO' && lesson.video_progress_percentage > 0">
|
|
<q-linear-progress
|
|
:value="lesson.video_progress_percentage / 100"
|
|
color="primary"
|
|
size="6px"
|
|
class="w-20"
|
|
rounded
|
|
/>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-expansion-item>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useQuasar } from 'quasar';
|
|
import {
|
|
instructorService,
|
|
type CourseDetailResponse,
|
|
type ChapterResponse,
|
|
type AnnouncementResponse,
|
|
type CreateAnnouncementRequest,
|
|
type CourseInstructorResponse,
|
|
type SearchInstructorResult,
|
|
type EnrolledStudentResponse,
|
|
type StudentDetailData
|
|
} from '~/services/instructor.service';
|
|
|
|
definePageMeta({
|
|
layout: 'instructor',
|
|
middleware: ['auth']
|
|
});
|
|
|
|
const $q = useQuasar();
|
|
const route = useRoute();
|
|
const authStore = useAuthStore();
|
|
|
|
// Data
|
|
const course = ref<CourseDetailResponse | null>(null);
|
|
const loading = ref(true);
|
|
const activeTab = ref('structure');
|
|
|
|
// Thumbnail upload
|
|
const uploadingThumbnail = ref(false);
|
|
const thumbnailInputRef = ref<HTMLInputElement | null>(null);
|
|
|
|
// 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
|
|
});
|
|
|
|
// Instructors data
|
|
const instructors = ref<CourseInstructorResponse[]>([]);
|
|
const loadingInstructors = ref(false);
|
|
const showAddInstructorDialog = ref(false);
|
|
const selectedUser = ref<SearchInstructorResult | null>(null);
|
|
const searchResults = ref<SearchInstructorResult[]>([]);
|
|
const loadingSearch = ref(false);
|
|
const addingInstructor = ref(false);
|
|
const searchQuery = ref('');
|
|
|
|
// Students data
|
|
const students = ref<EnrolledStudentResponse[]>([]);
|
|
const loadingStudents = ref(false);
|
|
const studentsPagination = ref({
|
|
page: 1,
|
|
limit: 10,
|
|
total: 0
|
|
});
|
|
const studentSearch = ref('');
|
|
const studentStatusFilter = ref('all');
|
|
const completedStudentsCount = ref(0);
|
|
const statusFilterOptions = [
|
|
{ label: 'สถานะทั้งหมด', value: 'all' },
|
|
{ label: 'กำลังเรียน', value: 'ENROLLED' },
|
|
{ label: 'เรียนจบแล้ว', value: 'COMPLETED' }
|
|
];
|
|
const limitOptions = [
|
|
{ label: '5 รายการ', value: 5 },
|
|
{ label: '10 รายการ', value: 10 },
|
|
{ label: '20 รายการ', value: 20 },
|
|
{ label: '50 รายการ', value: 50 }
|
|
];
|
|
|
|
// Student Detail Modal
|
|
const showStudentDetailModal = ref(false);
|
|
const loadingStudentDetail = ref(false);
|
|
const studentDetail = ref<StudentDetailData | null>(null);
|
|
|
|
// Attachment handling
|
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
|
const uploadingAttachment = ref(false);
|
|
const deletingAttachmentId = ref<number | null>(null);
|
|
const pendingFiles = ref<File[]>([]);
|
|
|
|
// Computed
|
|
const totalLessons = computed(() => {
|
|
if (!course.value) return 0;
|
|
return course.value.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
|
});
|
|
|
|
const sortedChapters = computed(() => {
|
|
if (!course.value) return [];
|
|
return course.value.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
|
|
});
|
|
|
|
// Check if current user is the primary instructor
|
|
const isPrimaryInstructor = computed(() => {
|
|
if (!authStore.user?.id) return false;
|
|
const currentUserId = parseInt(authStore.user.id);
|
|
const myInstructorRecord = instructors.value.find(i => i.user_id === currentUserId);
|
|
return myInstructorRecord?.is_primary === true;
|
|
});
|
|
|
|
// Filtered students (client-side filtering)
|
|
const filteredStudents = computed(() => {
|
|
let result = students.value;
|
|
|
|
// Filter by search query
|
|
if (studentSearch.value) {
|
|
const query = studentSearch.value.toLowerCase();
|
|
result = result.filter(s =>
|
|
s.first_name.toLowerCase().includes(query) ||
|
|
s.last_name.toLowerCase().includes(query) ||
|
|
s.email.toLowerCase().includes(query) ||
|
|
s.username.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Filter by status
|
|
if (studentStatusFilter.value !== 'all') {
|
|
result = result.filter(s => s.status === studentStatusFilter.value);
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
// Methods
|
|
const fetchCourse = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
course.value = await instructorService.getCourseById(courseId);
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้',
|
|
position: 'top'
|
|
});
|
|
navigateTo('/instructor/courses');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const triggerThumbnailUpload = () => {
|
|
thumbnailInputRef.value?.click();
|
|
};
|
|
|
|
const handleThumbnailUpload = 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;
|
|
}
|
|
|
|
uploadingThumbnail.value = true;
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
|
|
// Upload the file
|
|
const response = await instructorService.uploadCourseThumbnail(courseId, file);
|
|
|
|
// Wait for Vue to unmount old img
|
|
await nextTick();
|
|
|
|
// Re-fetch course data to get fresh presigned URL from backend
|
|
const updatedCourse = await instructorService.getCourseById(courseId);
|
|
|
|
// Update local state
|
|
if (course.value) {
|
|
course.value.thumbnail_url = updatedCourse.thumbnail_url;
|
|
}
|
|
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
|
|
// Clear input value to allow re-uploading same file if needed
|
|
input.value = '';
|
|
} catch (error: any) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาดในการอัพโหลดรูปภาพ',
|
|
position: 'top'
|
|
});
|
|
} finally {
|
|
uploadingThumbnail.value = false;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
const colors: Record<string, string> = {
|
|
APPROVED: 'green',
|
|
PENDING: 'orange',
|
|
DRAFT: 'grey',
|
|
REJECTED: 'red'
|
|
};
|
|
return colors[status] || 'grey';
|
|
};
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
const labels: Record<string, string> = {
|
|
APPROVED: 'อนุมัติแล้ว',
|
|
PENDING: 'รอตรวจสอบ',
|
|
DRAFT: 'แบบร่าง',
|
|
REJECTED: 'ถูกปฏิเสธ'
|
|
};
|
|
return labels[status] || status;
|
|
};
|
|
|
|
const getChapterDuration = (chapter: ChapterResponse) => {
|
|
return chapter.lessons.reduce((sum, l) => sum + l.duration_minutes, 0);
|
|
};
|
|
|
|
const getSortedLessons = (chapter: ChapterResponse) => {
|
|
return chapter.lessons.slice().sort((a, b) => a.sort_order - b.sort_order);
|
|
};
|
|
|
|
const fetchStudents = async (page: number = 1) => {
|
|
loadingStudents.value = true;
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
const response = await instructorService.getEnrolledStudents(
|
|
courseId,
|
|
page,
|
|
studentsPagination.value.limit,
|
|
studentSearch.value || undefined,
|
|
studentStatusFilter.value
|
|
);
|
|
students.value = response.data;
|
|
studentsPagination.value = {
|
|
page: response.page,
|
|
limit: response.limit,
|
|
total: response.total
|
|
};
|
|
|
|
// Count completed students (from current response)
|
|
completedStudentsCount.value = students.value.filter(s => s.status === 'COMPLETED').length;
|
|
} catch (error) {
|
|
console.error('Failed to fetch students:', error);
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลผู้เรียนได้', position: 'top' });
|
|
} finally {
|
|
loadingStudents.value = false;
|
|
}
|
|
};
|
|
|
|
const handleStudentSearch = () => {
|
|
studentsPagination.value.page = 1;
|
|
fetchStudents(1);
|
|
};
|
|
|
|
const handleLimitChange = () => {
|
|
studentsPagination.value.page = 1;
|
|
fetchStudents(1);
|
|
};
|
|
|
|
const openStudentDetail = async (studentId: number) => {
|
|
const courseId = parseInt(route.params.id as string);
|
|
showStudentDetailModal.value = true;
|
|
loadingStudentDetail.value = true;
|
|
studentDetail.value = null;
|
|
|
|
try {
|
|
studentDetail.value = await instructorService.getStudentDetail(courseId, studentId);
|
|
} catch (error) {
|
|
console.error('Failed to fetch student detail:', error);
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลผู้เรียนได้', position: 'top' });
|
|
showStudentDetailModal.value = false;
|
|
} finally {
|
|
loadingStudentDetail.value = false;
|
|
}
|
|
};
|
|
|
|
const getLessonTypeIcon = (type: string) => {
|
|
const icons: Record<string, string> = {
|
|
VIDEO: 'play_circle',
|
|
DOCUMENT: 'description',
|
|
QUIZ: 'quiz',
|
|
ASSIGNMENT: 'assignment'
|
|
};
|
|
return icons[type] || 'article';
|
|
};
|
|
|
|
const getLessonTypeLabel = (type: string) => {
|
|
const labels: Record<string, string> = {
|
|
VIDEO: 'วิดีโอ',
|
|
DOCUMENT: 'เอกสาร',
|
|
QUIZ: 'แบบทดสอบ',
|
|
ASSIGNMENT: 'แบบฝึกหัด'
|
|
};
|
|
return labels[type] || type;
|
|
};
|
|
|
|
const formatVideoTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const formatCompletedDate = (dateStr: string | null) => {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
|
|
};
|
|
|
|
const getStudentStatusColor = (status: string) => {
|
|
const colors: Record<string, string> = {
|
|
ENROLLED: 'blue',
|
|
COMPLETED: 'green',
|
|
DROPPED: 'red'
|
|
};
|
|
return colors[status] || 'grey';
|
|
};
|
|
|
|
const getStudentStatusLabel = (status: string) => {
|
|
const labels: Record<string, string> = {
|
|
ENROLLED: 'กำลังเรียน',
|
|
COMPLETED: 'เรียนจบแล้ว',
|
|
DROPPED: 'ยกเลิก'
|
|
};
|
|
return labels[status] || status;
|
|
};
|
|
|
|
const formatEnrollDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString('th-TH', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
};
|
|
|
|
const getLessonIcon = (type: string) => {
|
|
const icons: Record<string, string> = {
|
|
VIDEO: 'play_circle',
|
|
QUIZ: 'quiz',
|
|
DOCUMENT: 'description'
|
|
};
|
|
return icons[type] || 'article';
|
|
};
|
|
|
|
const getLessonIconColor = (type: string) => {
|
|
const colors: Record<string, string> = {
|
|
VIDEO: 'primary',
|
|
QUIZ: 'orange',
|
|
DOCUMENT: 'grey'
|
|
};
|
|
return colors[type] || 'grey';
|
|
};
|
|
|
|
const requestApproval = () => {
|
|
$q.dialog({
|
|
title: 'ขออนุมัติหลักสูตร',
|
|
message: 'ยืนยันการขออนุมัติหลักสูตรนี้?',
|
|
cancel: true,
|
|
persistent: true
|
|
}).onOk(async () => {
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
const response = await instructorService.sendForReview(courseId);
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
// Refresh course data
|
|
fetchCourse();
|
|
} catch (error: any) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: error.data?.error?.message || 'ไม่สามารถส่งคำขอได้',
|
|
position: 'top'
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
|
|
|
|
const fetchInstructors = async () => {
|
|
loadingInstructors.value = true;
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
instructors.value = await instructorService.getCourseInstructors(courseId);
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: 'ไม่สามารถโหลดข้อมูลผู้สอนได้',
|
|
position: 'top'
|
|
});
|
|
} finally {
|
|
loadingInstructors.value = false;
|
|
}
|
|
};
|
|
|
|
let searchTimeout: NodeJS.Timeout | null = null;
|
|
|
|
const filterUsers = (val: string, update: (callback: () => void) => void, abort: () => void) => {
|
|
// Abort if query is too short
|
|
if (val.length < 2) {
|
|
abort();
|
|
return;
|
|
}
|
|
|
|
// Clear previous timeout
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
}
|
|
|
|
// Debounce the search
|
|
searchTimeout = setTimeout(async () => {
|
|
loadingSearch.value = true;
|
|
try {
|
|
const results = await instructorService.searchInstructors(val);
|
|
const existingInstructorIds = instructors.value.map(i => i.user_id);
|
|
|
|
// Filter out existing instructors
|
|
update(() => {
|
|
searchResults.value = results.filter(r => !existingInstructorIds.includes(r.id));
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to search instructors', error);
|
|
update(() => {
|
|
searchResults.value = [];
|
|
});
|
|
} finally {
|
|
loadingSearch.value = false;
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const addInstructor = async () => {
|
|
if (!selectedUser.value) return;
|
|
|
|
addingInstructor.value = true;
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
const response = await instructorService.addInstructor(courseId, selectedUser.value.email);
|
|
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
|
|
showAddInstructorDialog.value = false;
|
|
selectedUser.value = null;
|
|
fetchInstructors();
|
|
} catch (error: any) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: error.data?.error?.message || error.data?.message || 'ไม่สามารถเพิ่มผู้สอนได้',
|
|
position: 'top'
|
|
});
|
|
} finally {
|
|
addingInstructor.value = false;
|
|
}
|
|
};
|
|
|
|
const removeInstructor = async (userId: number) => {
|
|
$q.dialog({
|
|
title: 'ยืนยันการลบ',
|
|
message: 'คุณต้องการลบผู้สอนท่านนี้ใช่หรือไม่?',
|
|
cancel: true,
|
|
persistent: true
|
|
}).onOk(async () => {
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
const response = await instructorService.removeInstructor(courseId, userId);
|
|
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
|
|
fetchInstructors();
|
|
} catch (error: any) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: error.data?.error?.message || error.data?.message || 'ไม่สามารถลบผู้สอนได้',
|
|
position: 'top'
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const setPrimaryInstructor = async (userId: number) => {
|
|
$q.dialog({
|
|
title: 'ยืนยันการเปลี่ยนหัวหน้าผู้สอน',
|
|
message: 'คุณต้องการตั้งให้ผู้สอนท่านนี้เป็นหัวหน้าผู้สอนใช่หรือไม่?',
|
|
cancel: true,
|
|
persistent: true
|
|
}).onOk(async () => {
|
|
try {
|
|
const courseId = parseInt(route.params.id as string);
|
|
const response = await instructorService.setPrimaryInstructor(courseId, userId);
|
|
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
|
|
fetchInstructors();
|
|
} catch (error: any) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: error.data?.error?.message || error.data?.message || 'ไม่สามารถเปลี่ยนหัวหน้าผู้สอนได้',
|
|
position: 'top'
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
// 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, includeTime = true) => {
|
|
const options: Intl.DateTimeFormatOptions = {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: '2-digit'
|
|
};
|
|
|
|
if (includeTime) {
|
|
options.hour = '2-digit';
|
|
options.minute = '2-digit';
|
|
}
|
|
|
|
return new Date(date).toLocaleDateString('th-TH', options);
|
|
};
|
|
|
|
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) {
|
|
const response = await instructorService.updateAnnouncement(courseId, editingAnnouncement.value.id, announcementForm.value);
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
} else {
|
|
// Create announcement with files
|
|
const response = await instructorService.createAnnouncement(
|
|
courseId,
|
|
announcementForm.value,
|
|
pendingFiles.value.length > 0 ? pendingFiles.value : undefined
|
|
);
|
|
pendingFiles.value = [];
|
|
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
}
|
|
showAnnouncementDialog.value = false;
|
|
fetchAnnouncements();
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (error as any).data?.error?.message || (error as any).data?.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);
|
|
const response = await instructorService.deleteAnnouncement(courseId, announcement.id);
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message,
|
|
position: 'top'
|
|
});
|
|
fetchAnnouncements();
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (error as any).data?.error?.message || (error as any).data?.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
|
|
);
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: updated.message,
|
|
position: 'top'
|
|
});
|
|
|
|
// Refresh list to get complete object including attachments
|
|
await fetchAnnouncements();
|
|
|
|
// Update the editing object from the fresh list
|
|
if (editingAnnouncement.value) {
|
|
const fresh = announcements.value.find(a => a.id === editingAnnouncement.value!.id);
|
|
if (fresh) {
|
|
editingAnnouncement.value = fresh;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (error as any).data?.error?.message || (error as any).data?.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);
|
|
const response = 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: response.message,
|
|
position: 'top'
|
|
});
|
|
fetchAnnouncements();
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: (error as any).data?.error?.message || (error as any).data?.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 data
|
|
watch(activeTab, (newTab) => {
|
|
if (newTab === 'announcements' && announcements.value.length === 0) {
|
|
fetchAnnouncements();
|
|
} else if (newTab === 'instructors' && instructors.value.length === 0) {
|
|
fetchInstructors();
|
|
} else if (newTab === 'students' && students.value.length === 0) {
|
|
fetchStudents();
|
|
}
|
|
});
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
fetchCourse();
|
|
});
|
|
</script>
|