elearning/frontend_management/pages/instructor/courses/index.vue
Missez 9dc8636d31
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 42s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
feat: Implement admin user and pending course management, instructor course listing, and a dedicated admin service.
2026-02-24 14:43:06 +07:00

563 lines
20 KiB
Vue

<template>
<div>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-primary-600">หลกสตรของฉ</h1>
<p class="text-gray-600 mt-1">ดการหลกสตรทณสราง</p>
</div>
<q-btn
color="primary"
label="+ สร้างหลักสูตรใหม่"
@click="navigateTo('/instructor/courses/create')"
/>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
<div class="text-3xl font-bold text-primary-600">{{ stats.total }}</div>
<div class="text-gray-500 text-sm mt-1">หลกสตรทงหมด</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
<div class="text-3xl font-bold text-green-600">{{ stats.approved }}</div>
<div class="text-gray-500 text-sm mt-1">เผยแพรแล</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
<div class="text-3xl font-bold text-yellow-600">{{ stats.pending }}</div>
<div class="text-gray-500 text-sm mt-1">รอตรวจสอบ</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
<div class="text-3xl font-bold text-gray-500">{{ stats.draft }}</div>
<div class="text-gray-500 text-sm mt-1">แบบราง</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
<div class="text-3xl font-bold text-red-600">{{ stats.rejected }}</div>
<div class="text-gray-500 text-sm mt-1">กปฏเสธ</div>
</div>
</div>
<!-- Filter Bar -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="flex gap-4 items-center">
<div class="flex-1">
<q-input
v-model="searchQuery"
placeholder="ค้นหาหลักสูตร..."
outlined
dense
debounce="600"
bg-color="grey-1"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
<q-select
v-model="filterStatus"
:options="statusOptions"
outlined
dense
emit-value
map-options
style="min-width: 160px"
/>
<q-btn-toggle
v-model="viewMode"
toggle-color="primary"
:options="[
{ value: 'card', slot: 'card' },
{ value: 'table', slot: 'table' }
]"
dense
rounded
unelevated
class="border"
>
<template v-slot:card>
<q-icon name="grid_view" size="20px" />
<q-tooltip>มุมมองการ์ด</q-tooltip>
</template>
<template v-slot:table>
<q-icon name="view_list" size="20px" />
<q-tooltip>มุมมองตาราง</q-tooltip>
</template>
</q-btn-toggle>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<q-spinner-dots size="50px" color="primary" />
</div>
<!-- Empty State -->
<div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center">
<q-icon name="school" size="60px" color="grey-5" class="mb-4" />
<p class="text-gray-500 text-lg">ยังไม่มีหลักสูตร</p>
<q-btn
color="primary"
label="สร้างหลักสูตรแรก"
class="mt-4"
@click="navigateTo('/instructor/courses/create')"
/>
</div>
<!-- Card View -->
<div v-else-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="course in filteredCourses"
:key="course.id"
class="bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-md transition-shadow"
>
<!-- Thumbnail -->
<div class="h-40 bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center relative overflow-hidden">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="course.title.th"
class="absolute inset-0 w-full h-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<q-icon v-else name="school" size="60px" color="white" />
</div>
<!-- Content -->
<div class="p-4">
<div class="flex justify-between items-start mb-2">
<h3 class="font-semibold text-gray-900 line-clamp-2">{{ course.title.th }}</h3>
<q-badge :color="getStatusColor(course.status)" class="ml-2">
{{ getStatusLabel(course.status) }}
</q-badge>
</div>
<p class="text-sm text-gray-500 line-clamp-2 mb-3">
{{ course.description.th }}
</p>
<div class="flex items-center justify-between text-sm">
<span class="font-semibold" :class="course.is_free ? 'text-green-600' : 'text-primary-600'">
{{ course.is_free ? 'ฟรี' : `฿${parseFloat(course.price).toLocaleString()}` }}
</span>
<span class="text-gray-400">
{{ formatDate(course.created_at) }}
</span>
</div>
</div>
<!-- Actions -->
<div class="border-t px-4 py-3 flex gap-2">
<q-btn
flat
dense
icon="visibility"
color="grey"
@click="handleViewDetails(course)"
>
<q-tooltip>ดูรายละเอียด</q-tooltip>
</q-btn>
<q-space />
<q-btn flat round dense icon="more_vert">
<q-menu>
<q-list style="min-width: 150px">
<q-item clickable v-close-popup @click="duplicateCourse(course)">
<q-item-section avatar>
<q-icon name="content_copy" />
</q-item-section>
<q-item-section>ทำสำเนา</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="confirmDelete(course)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ลบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
</div>
<!-- Table View -->
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden">
<q-table
:rows="filteredCourses"
:columns="tableColumns"
row-key="id"
flat
:pagination="tablePagination"
:rows-per-page-options="[10, 20, 50, 0]"
@update:pagination="tablePagination = $event"
>
<!-- Thumbnail + Title -->
<template v-slot:body-cell-title="props">
<q-td :props="props">
<div class="flex items-center gap-3">
<div class="w-16 h-10 rounded overflow-hidden flex-shrink-0 bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
<img
v-if="props.row.thumbnail_url"
:src="props.row.thumbnail_url"
:alt="props.row.title.th"
class="w-full h-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<q-icon v-else name="school" size="20px" color="white" />
</div>
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ props.row.title.th }}</div>
<div class="text-xs text-gray-400 truncate">{{ props.row.title.en }}</div>
</div>
</div>
</q-td>
</template>
<!-- Status Badge -->
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="getStatusColor(props.row.status)">
{{ getStatusLabel(props.row.status) }}
</q-badge>
</q-td>
</template>
<!-- Price -->
<template v-slot:body-cell-price="props">
<q-td :props="props">
<span class="font-medium" :class="props.row.is_free ? 'text-green-600' : 'text-primary-600'">
{{ props.row.is_free ? 'ฟรี' : `฿${parseFloat(props.row.price).toLocaleString()}` }}
</span>
</q-td>
</template>
<!-- Date -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.row.created_at) }}
</q-td>
</template>
<!-- Actions -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="grey" size="sm" @click="handleViewDetails(props.row)">
<q-tooltip>รายละเอยด</q-tooltip>
</q-btn>
<q-btn flat round dense icon="more_vert" size="sm">
<q-menu>
<q-list style="min-width: 150px">
<q-item clickable v-close-popup @click="duplicateCourse(props.row)">
<q-item-section avatar>
<q-icon name="content_copy" />
</q-item-section>
<q-item-section>ทำสำเนา</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="confirmDelete(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ลบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
</div>
<!-- Rejection Details Dialog -->
<q-dialog v-model="rejectionDialog">
<q-card style="min-width: 400px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6 text-red">หลกสตรถกปฏเสธ</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-subtitle1 font-bold mb-2">เหตผลการปฏเสธ:</div>
<div class="bg-red-50 p-4 rounded-lg text-red-800 border border-red-100">
{{ selectedRejectionCourse?.rejection_reason || 'ไม่ระบุเหตุผล' }}
</div>
<div class="text-gray-500 text-sm mt-4">
ณสามารถแกไขหลกสตรและสงขออนใหมได โดยการคนสถานะเปนแบบราง
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary q-pt-none q-pb-md q-px-md">
<q-btn flat label="ยกเลิก" v-close-popup color="grey" />
<q-btn
label="คืนสถานะเป็นแบบร่าง"
color="primary"
@click="returnToDraft"
/>
</q-card-actions>
</q-card>
</q-dialog>
<!-- Clone Course Dialog -->
<q-dialog v-model="cloneDialog">
<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 v-close-popup />
</q-card-section>
<q-card-section>
<div class="mb-4">
กรณาระบอสำหรบหลกสตรใหม
</div>
<q-input
v-model="cloneCourseTitleTh"
label="ชื่อหลักสูตร (ภาษาไทย)"
outlined
autofocus
class="mb-4"
:rules="[val => !!val || 'กรุณากรอกชื่อหลักสูตรภาษาไทย']"
/>
<q-input
v-model="cloneCourseTitleEn"
label="Course Name (English)"
outlined
:rules="[val => !!val || 'Please enter course name in English']"
/>
</q-card-section>
<q-card-actions align="right" class="text-primary q-pt-none q-pb-md q-px-md">
<q-btn flat label="ยกเลิก" v-close-popup color="grey" />
<q-btn
label="ยืนยันการทำสำเนา"
color="primary"
@click="confirmClone"
:loading="cloneLoading"
/>
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { instructorService, type CourseResponse } from '~/services/instructor.service';
definePageMeta({
layout: 'instructor',
middleware: ['auth']
});
const $q = useQuasar();
// Data
const courses = ref<CourseResponse[]>([]);
const loading = ref(true);
const searchQuery = ref('');
const filterStatus = ref<string | null>(null);
const viewMode = ref<'card' | 'table'>('card');
// Table config
const tablePagination = ref({ page: 1, rowsPerPage: 10 });
const tableColumns = [
{ name: 'title', label: 'หลักสูตร', field: 'title', align: 'left' as const, sortable: true },
{ name: 'status', label: 'สถานะ', field: 'status', align: 'center' as const, sortable: true },
{ name: 'price', label: 'ราคา', field: 'price', align: 'center' as const, sortable: true },
{ name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const, sortable: true },
{ name: 'actions', label: 'จัดการ', field: 'actions', align: 'center' as const }
];
// Status options
const statusOptions = [
{ label: 'สถานะทั้งหมด', value: null },
{ label: 'เผยแพร่แล้ว', value: 'APPROVED' },
{ label: 'รอตรวจสอบ', value: 'PENDING' },
{ label: 'แบบร่าง', value: 'DRAFT' },
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' },
//{ label: 'เก็บถาวร', value: 'ARCHIVED' }
];
// Stats
const stats = computed(() => ({
total: courses.value.length,
approved: courses.value.filter(c => c.status === 'APPROVED').length,
pending: courses.value.filter(c => c.status === 'PENDING').length,
draft: courses.value.filter(c => c.status === 'DRAFT').length,
rejected: courses.value.filter(c => c.status === 'REJECTED').length
}));
// Filtered courses (search only, status is handled server-side)
const filteredCourses = computed(() => {
let result = courses.value;
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(course =>
course.title.th.toLowerCase().includes(query) ||
course.title.en.toLowerCase().includes(query)
);
}
return result;
});
// Methods
const fetchCourses = async () => {
loading.value = true;
try {
courses.value = await instructorService.getCourses(filterStatus.value || undefined);
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
// Re-fetch when status filter changes
watch(filterStatus, () => {
fetchCourses();
});
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
APPROVED: 'green',
PENDING: 'orange',
DRAFT: 'grey',
REJECTED: 'red',
ARCHIVED: 'blue-grey'
};
return colors[status] || 'grey';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
APPROVED: 'เผยแพร่แล้ว',
PENDING: 'รอตรวจสอบ',
DRAFT: 'แบบร่าง',
REJECTED: 'ถูกปฏิเสธ',
ARCHIVED: 'เก็บถาวร'
};
return labels[status] || status;
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Clone Dialog
const cloneDialog = ref(false);
const cloneLoading = ref(false);
const cloneCourseTitleTh = ref('');
const cloneCourseTitleEn = ref('');
const courseToClone = ref<CourseResponse | null>(null);
const duplicateCourse = (course: CourseResponse) => {
courseToClone.value = course;
cloneCourseTitleTh.value = `${course.title.th} (Copy)`;
cloneCourseTitleEn.value = `${course.title.en} (Copy)`;
cloneDialog.value = true;
};
const confirmClone = async () => {
if (!courseToClone.value || !cloneCourseTitleTh.value || !cloneCourseTitleEn.value) return;
cloneLoading.value = true;
try {
const response = await instructorService.cloneCourse(courseToClone.value.id, cloneCourseTitleTh.value, cloneCourseTitleEn.value);
$q.notify({
type: 'positive',
message: response.message || 'ทำสำเนาหลักสูตรสำเร็จ',
position: 'top'
});
cloneDialog.value = false;
fetchCourses(); // Refresh list
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.message || 'ไม่สามารถทำสำเนาหลักสูตรได้',
position: 'top'
});
} finally {
cloneLoading.value = false;
}
};
const confirmDelete = (course: CourseResponse) => {
$q.dialog({
title: 'ยืนยันการลบ',
message: `คุณต้องการลบหลักสูตร "${course.title.th}" หรือไม่?`,
cancel: true,
persistent: true
}).onOk(async () => {
try {
const response = await instructorService.deleteCourse(course.id);
$q.notify({
type: 'positive',
message: response.message || 'ลบหลักสูตรสำเร็จ',
position: 'top'
});
// Refresh list
fetchCourses();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.message || 'ไม่สามารถลบหลักสูตรได้',
position: 'top'
});
}
});
};
// Rejection Dialog
const rejectionDialog = ref(false);
const selectedRejectionCourse = ref<CourseResponse | null>(null);
const handleViewDetails = (course: CourseResponse) => {
if (course.status === 'REJECTED') {
selectedRejectionCourse.value = course;
rejectionDialog.value = true;
} else {
navigateTo(`/instructor/courses/${course.id}`);
}
};
const returnToDraft = async () => {
if (!selectedRejectionCourse.value) return;
try {
const response = await instructorService.setCourseDraft(selectedRejectionCourse.value.id);
$q.notify({
type: 'positive',
message: response.message || 'คืนสถานะเป็นแบบร่างสำเร็จ',
position: 'top'
});
rejectionDialog.value = false;
selectedRejectionCourse.value = null;
fetchCourses(); // Refresh list
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.message || 'ไม่สามารถคืนสถานะได้',
position: 'top'
});
}
};
// Lifecycle
onMounted(() => {
fetchCourses();
});
</script>