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
329 lines
11 KiB
Vue
329 lines
11 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Header -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h1 class="text-2xl font-bold text-primary-600">คอร์สรออนุมัติ</h1>
|
|
<div class="flex gap-4">
|
|
<q-btn
|
|
outline
|
|
color="primary"
|
|
label="รีเฟรช"
|
|
icon="refresh"
|
|
:loading="loading"
|
|
@click="fetchPendingCourses"
|
|
/>
|
|
</div>
|
|
</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 & View Toggle -->
|
|
<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
|
|
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>
|
|
|
|
<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="view_stream" 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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Card View -->
|
|
<div v-else-if="viewMode === 'card'" 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>
|
|
|
|
<!-- Table View -->
|
|
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden">
|
|
<q-table
|
|
:rows="filteredCourses"
|
|
:columns="columns"
|
|
row-key="id"
|
|
flat
|
|
bordered
|
|
:pagination="{ rowsPerPage: 10 }"
|
|
>
|
|
<!-- Thumbnail Slot -->
|
|
<template v-slot:body-cell-thumbnail="props">
|
|
<q-td :props="props">
|
|
<div class="w-16 h-10 rounded overflow-hidden bg-gray-100">
|
|
<img
|
|
v-if="props.row.thumbnail_url"
|
|
:src="props.row.thumbnail_url"
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
<div v-else class="w-full h-full flex items-center justify-center">
|
|
<q-icon name="school" color="grey" />
|
|
</div>
|
|
</div>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Title Slot -->
|
|
<template v-slot:body-cell-title="props">
|
|
<q-td :props="props">
|
|
<div class="font-semibold">{{ props.row.title.th }}</div>
|
|
<div class="text-xs text-gray-500">{{ props.row.title.en }}</div>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Stats Slot -->
|
|
<template v-slot:body-cell-stats="props">
|
|
<q-td :props="props">
|
|
<div class="text-xs">
|
|
<div>{{ props.row.chapters_count }} บท</div>
|
|
<div>{{ props.row.lessons_count }} บทเรียน</div>
|
|
</div>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Submission Slot -->
|
|
<template v-slot:body-cell-submitted_at="props">
|
|
<q-td :props="props">
|
|
<div v-if="props.row.latest_submission">
|
|
<div class="text-xs">{{ formatDate(props.row.latest_submission.created_at) }}</div>
|
|
<div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
|
|
</div>
|
|
<span v-else>-</span>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Actions Slot -->
|
|
<template v-slot:body-cell-actions="props">
|
|
<q-td :props="props">
|
|
<q-btn
|
|
flat
|
|
round
|
|
color="primary"
|
|
icon="visibility"
|
|
@click="viewCourse(props.row)"
|
|
>
|
|
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
|
</q-btn>
|
|
</q-td>
|
|
</template>
|
|
</q-table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useQuasar, type QTableColumn } 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('');
|
|
const viewMode = ref('table');
|
|
|
|
const columns: QTableColumn[] = [
|
|
{ name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' },
|
|
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: any) => row.title.th, align: 'left', sortable: true },
|
|
{ name: 'instructor', label: 'ผู้สอน', field: (row: any) => getPrimaryInstructor(row), align: 'left', sortable: true },
|
|
{ name: 'stats', label: 'จำนวนบท', field: 'stats', align: 'center' },
|
|
{ name: 'submitted_at', label: 'วันที่ส่ง', field: (row: any) => row.latest_submission?.created_at, align: 'left', sortable: true },
|
|
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
|
];
|
|
|
|
// 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: (error as any).data?.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>
|