Compare commits

...

6 commits

Author SHA1 Message Date
supalerk-ar66
e02da48f7c feat: Implement the course discovery and catalog page, including filtering, search, and course detail view.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 16s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 17:34:27 +07:00
Missez
ae32cfebe4 feat: add utils/date.ts and stores api/user/me
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 56s
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
2026-03-06 17:33:01 +07:00
Missez
ea442d7815 del readme 2026-03-06 15:58:58 +07:00
Missez
ac768a3df4 add readme testresult
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 53s
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
2026-03-06 15:53:13 +07:00
Missez
9e4fcbf04e Add tests result 2026-03-06 15:47:43 +07:00
supalerk-ar66
853c141910 feat: Add i18n support with English and Thai locales and introduce new browse pages.
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 51s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 4s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
2026-03-06 13:33:58 +07:00
130 changed files with 1300 additions and 367 deletions

View file

@ -117,7 +117,11 @@
"foundTotal": "Found Total",
"items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search"
"searchBtn": "Search",
"allCategory": "All",
"byInstructor": "by",
"students": "students",
"viewDetails": "View Details"
},
"myCourses": {
"title": "My Courses",

View file

@ -117,7 +117,11 @@
"foundTotal": "พบทั้งหมด",
"items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา"
"searchBtn": "ค้นหา",
"allCategory": "ทั้งหมด",
"byInstructor": "โดย",
"students": "นักเรียน",
"viewDetails": "ดูรายละเอียด"
},
"myCourses": {
"title": "คอร์สของฉัน",

View file

@ -180,19 +180,19 @@ onMounted(async () => {
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
<div class="max-w-[1240px] mx-auto">
<!-- วนของการคนหาคอร (Catalog View) -->
<div v-if="!showDetail" class="bg-white dark:bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12">
<div v-if="!showDetail" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:!border-slate-800 min-h-[500px] mb-12 transition-colors">
<!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2>
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-[#f8fafc] tracking-tight">{{ $t('discovery.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div>
</div>
</div>
@ -203,17 +203,17 @@ onMounted(async () => {
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button
@click="activeCategory = 'all'"
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-400 dark:!text-slate-400'"/> {{ $t('discovery.allCategory') }}
</button>
<button
v-for="cat in categories" :key="cat.id"
@click="activeCategory = cat.id"
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-600 dark:!text-slate-400'"/>
{{ getLocalizedText(cat.name) }}
</button>
</div>
@ -229,7 +229,7 @@ onMounted(async () => {
<div v-else-if="filteredCourses.length > 0">
<!-- GRID VIEW -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<!-- Thumbnail -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
@ -240,7 +240,7 @@ onMounted(async () => {
<!-- Body -->
<div class="p-5 flex flex-col flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h3>
<h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
@ -248,8 +248,7 @@ onMounted(async () => {
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<!-- Eye icon circle button -->
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-slate-700 border border-slate-100 dark:border-slate-700 transition-colors shadow-sm outline-none">
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:!bg-slate-800 text-slate-400 dark:!text-slate-300 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors shadow-sm outline-none">
<q-icon name="visibility" size="18px" />
</button>
</div>
@ -259,7 +258,7 @@ onMounted(async () => {
<!-- LIST VIEW -->
<div v-else class="flex flex-col gap-5">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
@ -268,15 +267,15 @@ onMounted(async () => {
</div>
<div class="flex flex-col flex-1 py-1">
<div class="flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2">{{ getLocalizedText(course.title) }}</h3>
<h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
</div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:!bg-slate-800 dark:!text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors">
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
</button>
</div>
</div>
@ -290,9 +289,9 @@ onMounted(async () => {
</div>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:!bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:!border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<h3 class="text-xl font-bold text-slate-900 dark:!text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
{{ $t("discovery.showAll") }}

View file

@ -13,6 +13,7 @@ useHead({
title: 'คอร์สทั้งหมด - E-Learning System'
})
const { t } = useI18n()
const searchQuery = ref('')
const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory()
@ -131,11 +132,11 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2>
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('discovery.title') }}</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
@ -151,7 +152,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
@click="selectCategory('all')"
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> {{ $t('discovery.allCategory') }}
</button>
<button
@ -187,13 +188,13 @@ const viewMode = ref<'grid' | 'list'>('grid')
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-4">
<span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
<span class="text-[12px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
</div>
<div class="flex items-center gap-1.5 mb-5">
<q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
</div>
<div class="mt-auto flex items-center justify-between">
@ -222,12 +223,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
<div class="flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-3">
<span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
<span class="text-[13px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
</div>
<div class="flex items-center gap-1.5 mb-2">
<q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
</div>
</div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
@ -235,7 +236,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
</button>
</div>
</div>
@ -246,10 +247,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- กรณไมพบขอมลคอร (Empty State) -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t('discovery.emptyDesc') }}</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
แสดงคอรสทงหมด
{{ $t('discovery.showAll') }}
</button>
</div>
</div>

View file

@ -343,10 +343,12 @@ const save = async () => {
saving.value = true;
try {
// Convert local datetime to ISO string to preserve timezone
const payload = { ...form.value };
const payload: any = { ...form.value };
if (payload.published_at) {
const localDate = new Date(payload.published_at.replace(' ', 'T'));
payload.published_at = localDate.toISOString();
} else {
delete payload.published_at;
}
if (editing.value) {
@ -447,10 +449,7 @@ const deleteAttachment = async (attachmentId: number) => {
}
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
};
// Date formatting function is auto-imported from utils/date.ts
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';

View file

@ -20,7 +20,7 @@
v-for="item in history"
:key="item.id"
:title="titleMap[item.action] || item.action"
:subtitle="formatDate(item.created_at)"
:subtitle="formatDateTime(item.created_at)"
:color="colorMap[item.action] || 'grey'"
:icon="iconMap[item.action] || 'circle'"
>
@ -91,12 +91,7 @@ const getActorName = (item: ApprovalHistory) => {
return actor.username || actor.email || 'Unknown User';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('th-TH', {
dateStyle: 'medium',
timeStyle: 'short'
});
};
// Date formatting function is auto-imported from utils/date.ts
onMounted(() => {
fetchHistory();

View file

@ -450,14 +450,7 @@ const openStudentDetail = async (studentId: number) => {
const formatDate = (dateStr: string) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return formatDateTime(dateStr);
};
// Lifecycle

View file

@ -404,8 +404,7 @@ const getStudentStatusLabel = (status: string) => {
};
const formatEnrollDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
return formatDate(dateStr);
};
const getLessonTypeIcon = (type: string) => {
@ -436,8 +435,7 @@ const formatVideoTime = (seconds: number) => {
const formatCompletedDate = (dateStr: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
return formatDate(dateStr);
};
// Fetch on mount

View file

@ -136,7 +136,7 @@
<!-- Created At Custom Column -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.value) }}
{{ formatDateTime(props.value) }}
</q-td>
</template>
@ -169,7 +169,7 @@
</div>
<div>
<div class="text-subtitle2 text-grey">Date & Time</div>
<div>{{ formatDate(selectedLog.created_at) }}</div>
<div>{{ formatDateTime(selectedLog.created_at) }}</div>
</div>
<div>
@ -241,7 +241,7 @@
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { useQuasar, type QTableColumn } from 'quasar';
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
definePageMeta({
@ -284,15 +284,15 @@ const pagination = ref({
});
// Table setup
const columns = [
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
{ name: 'user', label: 'User', field: 'user', align: 'left' },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
const columns: QTableColumn[] = [
{ name: 'id', label: 'ID', field: 'id', align: 'left' as const, style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' as const },
{ name: 'user', label: 'User', field: 'user', align: 'left' as const },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' as const },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' as const },
{ name: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'center' }
{ name: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' as const },
{ name: 'actions', label: '', field: 'actions', align: 'center' as const }
];
// Actions options (for filtering)
@ -416,10 +416,7 @@ const tryFormatJson = (str: string | null) => {
}
};
const formatDate = (date: string) => {
if (!date) return '-';
return new Date(date).toLocaleString('th-TH');
};
// Date formatting function is auto-imported from utils/date.ts
const ACTION_COLOR_MAP: Record<string, string> = {
DELETE: 'negative',
@ -453,10 +450,12 @@ onMounted(() => {
:deep(input[type=number]::-webkit-outer-spin-button),
:deep(input[type=number]::-webkit-inner-spin-button) {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
:deep(input[type=number]) {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -233,13 +233,7 @@ const fetchCategories = async () => {
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
// Date formatting function is auto-imported from utils/date.ts
const resetForm = () => {
form.value = {

View file

@ -356,23 +356,7 @@ const getActionColor = (action: string) => {
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'
});
};
// Date formatting functions are auto-imported from utils/date.ts
const confirmApprove = () => {
if (!course.value) return;

View file

@ -135,7 +135,7 @@
<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) }}
เม {{ formatDateTime(course.latest_submission.created_at) }}
</div>
</div>
@ -203,7 +203,7 @@
<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">{{ formatDateTime(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>
@ -298,15 +298,7 @@ const getPrimaryInstructor = (course: PendingCourse) => {
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'
});
};
// Date formatting function is auto-imported from utils/date.ts
const viewCourse = (course: PendingCourse) => {
router.push(`/admin/courses/${course.id}`);

View file

@ -136,7 +136,7 @@
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
</div>
<div class="text-xs text-gray-400 whitespace-nowrap">
{{ formatDate(course.created_at) }}
{{ formatDateStr(course.created_at) }}
</div>
</div>
</div>
@ -170,7 +170,7 @@
<span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span>
<span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span>
</p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(log.created_at) }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDateStr(log.created_at) }}</p>
</div>
</div>
</div>
@ -254,14 +254,7 @@ const fetchDashboardData = async () => {
};
// Utilities
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDateStr = (date: string) => formatDateTime(date);
const getActionIcon = (action: string) => {
if (action.includes('create')) return 'add_circle';

View file

@ -301,7 +301,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { userService } from '~/services/user.service';
import { authService } from '~/services/auth.service';
definePageMeta({
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
return labels[role] || role;
};
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);
};
// Use formatting utilities from utils/date.ts
// Format functions are auto-imported
// Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
try {
const response = await userService.uploadAvatar(file);
// Re-fetch profile to get presigned URL from backend
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null
});
// Refresh profile data from API
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -546,13 +534,9 @@ watch(showEditModal, (newVal) => {
}
});
// Fetch profile from API
const fetchProfile = async () => {
loading.value = true;
try {
const data = await userService.getProfile();
// Map API response to profile
// Helper to map fullProfile to local profile state
const mapProfileData = (data: typeof authStore.fullProfile) => {
if (!data) return;
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
@ -565,6 +549,14 @@ const fetchProfile = async () => {
avatarUrl: data.profile.avatar_url,
createdAt: data.created_at
};
};
// Fetch profile uses auth store cache, force=true to refresh
const fetchProfile = async (force = false) => {
loading.value = true;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
} catch (error) {
$q.notify({
type: 'negative',
@ -576,7 +568,7 @@ const fetchProfile = async () => {
}
};
// Load profile on mount
// Load profile on mount (uses cache if available)
onMounted(() => {
fetchProfile();
});

View file

@ -324,13 +324,7 @@ const getRoleBadgeColor = (roleCode: string) => {
return colors[roleCode] || 'grey';
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Date formatting function is auto-imported from utils/date.ts
const viewUser = (user: AdminUserResponse) => {
selectedUser.value = user;

View file

@ -449,13 +449,7 @@ const getStatusLabel = (status: string) => {
return labels[status] || status;
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Date formatting function is auto-imported from utils/date.ts
// Clone Dialog
const cloneDialog = ref(false);
const cloneLoading = ref(false);

View file

@ -64,21 +64,21 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-primary-600 mb-2">
{{ instructorStore.stats.totalCourses }}
{{ stats.totalCourses }}
</div>
<div class="text-gray-600">หลกสตรทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-secondary-600 mb-2">
{{ instructorStore.stats.totalStudents }}
{{ stats.totalStudents }}
</div>
<div class="text-gray-600">เรยนทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-accent-600 mb-2">
{{ instructorStore.stats.completedStudents }}
{{ stats.completedStudents }}
</div>
<div class="text-gray-600">เรยนจบแล</div>
</q-card>
@ -96,28 +96,28 @@
<q-icon name="check_circle" color="green" size="24px" />
<span class="font-medium text-gray-700">เผยแพรแล</span>
</div>
<span class="text-2xl font-bold text-green-600">{{ instructorStore.courseStatusCounts.approved }}</span>
<span class="text-2xl font-bold text-green-600">{{ courseStatusCounts.approved }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="hourglass_empty" color="orange" size="24px" />
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
</div>
<span class="text-2xl font-bold text-orange-600">{{ instructorStore.courseStatusCounts.pending }}</span>
<span class="text-2xl font-bold text-orange-600">{{ courseStatusCounts.pending }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="edit_note" color="grey" size="24px" />
<span class="font-medium text-gray-700">แบบราง</span>
</div>
<span class="text-2xl font-bold text-gray-600">{{ instructorStore.courseStatusCounts.draft }}</span>
<span class="text-2xl font-bold text-gray-600">{{ courseStatusCounts.draft }}</span>
</div>
<div v-if="instructorStore.courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div v-if="courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="cancel" color="red" size="24px" />
<span class="font-medium text-gray-700">กปฏเสธ</span>
</div>
<span class="text-2xl font-bold text-red-600">{{ instructorStore.courseStatusCounts.rejected }}</span>
<span class="text-2xl font-bold text-red-600">{{ courseStatusCounts.rejected }}</span>
</div>
</div>
</q-card-section>
@ -138,7 +138,7 @@
<div class="space-y-4">
<q-card
v-for="course in instructorStore.recentCourses"
v-for="course in recentCourses"
:key="course.id"
class="cursor-pointer hover:shadow-md transition"
@click="router.push(`/instructor/courses/${course.id}`)"
@ -172,6 +172,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { instructorService } from '~/services/instructor.service';
definePageMeta({
layout: 'instructor',
@ -179,10 +180,32 @@ definePageMeta({
});
const authStore = useAuthStore();
const instructorStore = useInstructorStore();
const router = useRouter();
const $q = useQuasar();
// Dashboard local state
const stats = ref({
totalCourses: 0,
totalStudents: 0,
completedStudents: 0
});
const courseStatusCounts = ref({
approved: 0,
pending: 0,
draft: 0,
rejected: 0
});
const recentCourses = ref<{
id: number;
title: string;
students: number;
lessons: number;
icon: string;
thumbnail: string | null;
}[]>([]);
// Navigation functions
const goToProfile = () => {
router.push('/instructor/profile');
@ -212,9 +235,41 @@ const handleLogout = () => {
});
};
// Fetch dashboard data on mount
// Fetch dashboard data
const fetchDashboardData = async () => {
try {
const [courses, studentStats] = await Promise.all([
instructorService.getCourses(),
instructorService.getMyStudentsStats()
]);
stats.value.totalCourses = courses.length;
stats.value.totalStudents = studentStats.total_students;
stats.value.completedStudents = studentStats.total_completed;
courseStatusCounts.value = {
approved: courses.filter(c => c.status === 'APPROVED').length,
pending: courses.filter(c => c.status === 'PENDING').length,
draft: courses.filter(c => c.status === 'DRAFT').length,
rejected: courses.filter(c => c.status === 'REJECTED').length
};
recentCourses.value = courses.slice(0, 3).map(course => ({
id: course.id,
title: course.title.th,
students: 0,
lessons: 0,
icon: 'book',
thumbnail: course.thumbnail_url || null
}));
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
}
};
// Fetch data on mount
onMounted(() => {
instructorStore.fetchDashboardData();
authStore.fetchUserProfile();
fetchDashboardData();
});
</script>

View file

@ -301,7 +301,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { userService } from '~/services/user.service';
import { authService } from '~/services/auth.service';
definePageMeta({
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
return labels[role] || role;
};
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);
};
// Use formatting utilities from utils/date.ts
// Format functions are auto-imported
// Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
try {
const response = await userService.uploadAvatar(file);
// Re-fetch profile to get presigned URL from backend
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null
});
// Refresh profile data from API
await fetchProfile();
// Force refresh profile cache and update local state
await fetchProfile(true);
$q.notify({
type: 'positive',
@ -546,13 +534,9 @@ watch(showEditModal, (newVal) => {
}
});
// Fetch profile from API
const fetchProfile = async () => {
loading.value = true;
try {
const data = await userService.getProfile();
// Map API response to profile
// Helper to map fullProfile to local profile state
const mapProfileData = (data: typeof authStore.fullProfile) => {
if (!data) return;
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
@ -565,6 +549,14 @@ const fetchProfile = async () => {
avatarUrl: data.profile.avatar_url,
createdAt: data.created_at
};
};
// Fetch profile uses auth store cache, force=true to refresh
const fetchProfile = async (force = false) => {
loading.value = true;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
} catch (error) {
$q.notify({
type: 'negative',
@ -576,7 +568,7 @@ const fetchProfile = async () => {
}
};
// Load profile on mount
// Load profile on mount (uses cache if available)
onMounted(() => {
fetchProfile();
});

View file

@ -34,11 +34,11 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
headless: false, // false = เห็น browser ขณะรัน
screenshot: 'only-on-failure', // เก็บ screenshot เมื่อ fail
screenshot: 'on', // เก็บ screenshot
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
// launchOptions: {
// slowMo: 1000,
// }, // ช้าลง 10 วินาที
launchOptions: {
slowMo: 500,
}, // ช้าลง 10 วินาที
},
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */

View file

@ -610,6 +610,19 @@ export const instructorService = {
{ method: 'DELETE' }
);
},
async getMyStudentsStats(): Promise<{ total_students: number; total_completed: number }> {
const response = await authRequest<{
code: number;
message: string;
total_students: number;
total_completed: number;
}>('/api/instructors/courses/my-students');
return {
total_students: response.total_students,
total_completed: response.total_completed
};
},
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
const response = await authRequest<{
code: number;

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { authService } from '~/services/auth.service';
import { userService } from '~/services/user.service';
import { userService, type UserProfileResponse } from '~/services/user.service';
interface User {
id: string;
@ -15,7 +15,8 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: null as string | null,
isAuthenticated: false
isAuthenticated: false,
fullProfile: null as UserProfileResponse | null
}),
getters: {
@ -61,6 +62,7 @@ export const useAuthStore = defineStore('auth', {
this.user = null;
this.token = null;
this.isAuthenticated = false;
this.fullProfile = null;
// Clear cookies
const tokenCookie = useCookie('token');
@ -126,10 +128,16 @@ export const useAuthStore = defineStore('auth', {
}
},
async fetchUserProfile() {
async fetchUserProfile(force = false) {
// Skip if already cached (unless force refresh)
if (!force && this.fullProfile) return;
try {
const response = await userService.getProfile();
// Cache raw API response
this.fullProfile = response;
// Update local user state
this.user = {
id: response.id.toString(),

View file

@ -1,122 +0,0 @@
import { defineStore } from 'pinia';
import { instructorService } from '~/services/instructor.service';
interface Course {
id: number;
title: string;
students: number;
lessons: number;
icon: string;
thumbnail: string | null;
}
interface DashboardStats {
totalCourses: number;
totalStudents: number;
completedStudents: number;
}
interface CourseStatusCounts {
approved: number;
pending: number;
draft: number;
rejected: number;
}
export const useInstructorStore = defineStore('instructor', {
state: () => ({
stats: {
totalCourses: 0,
totalStudents: 0,
completedStudents: 0
} as DashboardStats,
courseStatusCounts: {
approved: 0,
pending: 0,
draft: 0,
rejected: 0
} as CourseStatusCounts,
recentCourses: [] as Course[],
loading: false
}),
getters: {
getDashboardStats: (state) => state.stats,
getRecentCourses: (state) => state.recentCourses
},
actions: {
async fetchDashboardData() {
this.loading = true;
try {
// Fetch real courses from API
const courses = await instructorService.getCourses();
// Fetch student counts for each course
let totalStudents = 0;
let completedStudents = 0;
const courseDetails: Course[] = [];
for (const course of courses.slice(0, 5)) {
try {
// Get student counts
const studentsResponse = await instructorService.getEnrolledStudents(course.id, 1, 1);
const courseStudents = studentsResponse.total || 0;
totalStudents += courseStudents;
// Get completed count from full list (if small) or estimate
if (courseStudents > 0 && courseStudents <= 100) {
const allStudents = await instructorService.getEnrolledStudents(course.id, 1, 100);
completedStudents += allStudents.data.filter(s => s.status === 'COMPLETED').length;
}
// Get lesson count from course detail
const courseDetail = await instructorService.getCourseById(course.id);
const lessonCount = courseDetail.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
courseDetails.push({
id: course.id,
title: course.title.th,
students: courseStudents,
lessons: lessonCount,
icon: 'book',
thumbnail: course.thumbnail_url || null
});
} catch (e) {
// Course might not have students endpoint
courseDetails.push({
id: course.id,
title: course.title.th,
students: 0,
lessons: 0,
icon: 'book',
thumbnail: course.thumbnail_url || null
});
}
}
// Update stats
this.stats.totalCourses = courses.length;
this.stats.totalStudents = totalStudents;
this.stats.completedStudents = completedStudents;
// Update course status counts
this.courseStatusCounts = {
approved: courses.filter(c => c.status === 'APPROVED').length,
pending: courses.filter(c => c.status === 'PENDING').length,
draft: courses.filter(c => c.status === 'DRAFT').length,
rejected: courses.filter(c => c.status === 'REJECTED').length
};
// Update recent courses (first 3)
this.recentCourses = courseDetails.slice(0, 3);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
this.loading = false;
}
}
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Some files were not shown because too many files have changed in this diff Show more