feat: implement instructor courses listing page and data service
This commit is contained in:
parent
946d6ea0ca
commit
dec0fc5da0
2 changed files with 415 additions and 0 deletions
284
frontend_management/pages/instructor/courses/index.vue
Normal file
284
frontend_management/pages/instructor/courses/index.vue
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
<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-4 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>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<q-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="ค้นหาหลักสูตร..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Courses Grid -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-10">
|
||||||
|
<q-spinner-dots size="50px" color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div v-else 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">
|
||||||
|
<q-icon 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="navigateTo(`/instructor/courses/${course.id}`)"
|
||||||
|
>
|
||||||
|
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="edit"
|
||||||
|
color="primary"
|
||||||
|
@click="navigateTo(`/instructor/courses/${course.id}/edit`)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</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);
|
||||||
|
|
||||||
|
// Status options
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'สถานะทั้งหมด', value: null },
|
||||||
|
{ label: 'เผยแพร่แล้ว', value: 'APPROVED' },
|
||||||
|
{ label: 'รอตรวจสอบ', value: 'PENDING' },
|
||||||
|
{ label: 'แบบร่าง', value: 'DRAFT' },
|
||||||
|
{ label: 'ถูกปฏิเสธ', value: 'REJECTED' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filtered courses
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterStatus.value) {
|
||||||
|
result = result.filter(course => course.status === filterStatus.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchCourses = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
courses.value = await instructorService.getCourses();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
APPROVED: 'green',
|
||||||
|
PENDING: 'yellow',
|
||||||
|
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 formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateCourse = (course: CourseResponse) => {
|
||||||
|
$q.notify({
|
||||||
|
type: 'info',
|
||||||
|
message: `กำลังทำสำเนา "${course.title.th}"...`,
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (course: CourseResponse) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ยืนยันการลบ',
|
||||||
|
message: `คุณต้องการลบหลักสูตร "${course.title.th}" หรือไม่?`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(() => {
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ลบหลักสูตรสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCourses();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
131
frontend_management/services/instructor.service.ts
Normal file
131
frontend_management/services/instructor.service.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
// Course Response structure
|
||||||
|
export interface CourseResponse {
|
||||||
|
id: number;
|
||||||
|
category_id: number;
|
||||||
|
title: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
description: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
price: string;
|
||||||
|
is_free: boolean;
|
||||||
|
have_certificate: boolean;
|
||||||
|
status: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||||
|
approved_by: number | null;
|
||||||
|
approved_at: string | null;
|
||||||
|
rejection_reason: string | null;
|
||||||
|
created_at: string;
|
||||||
|
created_by: number;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoursesListResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: CourseResponse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get auth token from cookie
|
||||||
|
const getAuthToken = (): string => {
|
||||||
|
const tokenCookie = useCookie('token');
|
||||||
|
return tokenCookie.value || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock courses data
|
||||||
|
const MOCK_COURSES: CourseResponse[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
category_id: 1,
|
||||||
|
title: { en: 'JavaScript Fundamentals', th: 'พื้นฐาน JavaScript' },
|
||||||
|
slug: 'javascript-fundamentals',
|
||||||
|
description: {
|
||||||
|
en: 'Learn JavaScript fundamentals from scratch',
|
||||||
|
th: 'เรียนรู้พื้นฐาน JavaScript ตั้งแต่เริ่มต้น'
|
||||||
|
},
|
||||||
|
thumbnail_url: null,
|
||||||
|
price: '0',
|
||||||
|
is_free: true,
|
||||||
|
have_certificate: true,
|
||||||
|
status: 'APPROVED',
|
||||||
|
approved_by: 1,
|
||||||
|
approved_at: '2024-01-15T00:00:00Z',
|
||||||
|
rejection_reason: null,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
created_by: 2,
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_by: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
category_id: 2,
|
||||||
|
title: { en: 'React for Beginners', th: 'React สำหรับผู้เริ่มต้น' },
|
||||||
|
slug: 'react-for-beginners',
|
||||||
|
description: {
|
||||||
|
en: 'Build modern web apps with React',
|
||||||
|
th: 'สร้างเว็บแอปพลิเคชันด้วย React'
|
||||||
|
},
|
||||||
|
thumbnail_url: null,
|
||||||
|
price: '990',
|
||||||
|
is_free: false,
|
||||||
|
have_certificate: true,
|
||||||
|
status: 'PENDING',
|
||||||
|
approved_by: null,
|
||||||
|
approved_at: null,
|
||||||
|
rejection_reason: null,
|
||||||
|
created_at: '2024-02-01T00:00:00Z',
|
||||||
|
created_by: 2,
|
||||||
|
updated_at: '2024-02-01T00:00:00Z',
|
||||||
|
updated_by: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
category_id: 1,
|
||||||
|
title: { en: 'TypeScript Masterclass', th: 'TypeScript ขั้นสูง' },
|
||||||
|
slug: 'typescript-masterclass',
|
||||||
|
description: {
|
||||||
|
en: 'Master TypeScript for better JavaScript development',
|
||||||
|
th: 'เรียนรู้ TypeScript เพื่อพัฒนา JavaScript ได้ดียิ่งขึ้น'
|
||||||
|
},
|
||||||
|
thumbnail_url: null,
|
||||||
|
price: '1290',
|
||||||
|
is_free: false,
|
||||||
|
have_certificate: true,
|
||||||
|
status: 'DRAFT',
|
||||||
|
approved_by: null,
|
||||||
|
approved_at: null,
|
||||||
|
rejection_reason: null,
|
||||||
|
created_at: '2024-02-15T00:00:00Z',
|
||||||
|
created_by: 2,
|
||||||
|
updated_at: '2024-02-15T00:00:00Z',
|
||||||
|
updated_by: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const instructorService = {
|
||||||
|
async getCourses(): Promise<CourseResponse[]> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_COURSES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<CoursesListResponse>('/api/instructors/courses', {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue