Compare commits
6 commits
learner-de
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e02da48f7c | ||
|
|
ae32cfebe4 | ||
|
|
ea442d7815 | ||
|
|
ac768a3df4 | ||
|
|
9e4fcbf04e | ||
|
|
853c141910 |
|
|
@ -117,7 +117,11 @@
|
||||||
"foundTotal": "Found Total",
|
"foundTotal": "Found Total",
|
||||||
"items": "items",
|
"items": "items",
|
||||||
"subtitle": "Choose to learn new skills from our curated quality courses",
|
"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": {
|
"myCourses": {
|
||||||
"title": "My Courses",
|
"title": "My Courses",
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,11 @@
|
||||||
"foundTotal": "พบทั้งหมด",
|
"foundTotal": "พบทั้งหมด",
|
||||||
"items": "รายการ",
|
"items": "รายการ",
|
||||||
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
|
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
|
||||||
"searchBtn": "ค้นหา"
|
"searchBtn": "ค้นหา",
|
||||||
|
"allCategory": "ทั้งหมด",
|
||||||
|
"byInstructor": "โดย",
|
||||||
|
"students": "นักเรียน",
|
||||||
|
"viewDetails": "ดูรายละเอียด"
|
||||||
},
|
},
|
||||||
"myCourses": {
|
"myCourses": {
|
||||||
"title": "คอร์สของฉัน",
|
"title": "คอร์สของฉัน",
|
||||||
|
|
|
||||||
|
|
@ -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="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
|
||||||
<div class="max-w-[1240px] mx-auto">
|
<div class="max-w-[1240px] mx-auto">
|
||||||
<!-- ส่วนของการค้นหาคอร์ส (Catalog View) -->
|
<!-- ส่วนของการค้นหาคอร์ส (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">
|
<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="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">
|
<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" />
|
<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')" />
|
<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>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<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 = '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] 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 = '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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -203,17 +203,17 @@ onMounted(async () => {
|
||||||
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
|
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
|
||||||
<button
|
<button
|
||||||
@click="activeCategory = 'all'"
|
@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">
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories" :key="cat.id"
|
v-for="cat in categories" :key="cat.id"
|
||||||
@click="activeCategory = 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="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 bg-transparent">
|
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]' : 'text-slate-600 dark:text-slate-400'"/>
|
<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) }}
|
{{ getLocalizedText(cat.name) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,7 +229,7 @@ onMounted(async () => {
|
||||||
<div v-else-if="filteredCourses.length > 0">
|
<div v-else-if="filteredCourses.length > 0">
|
||||||
<!-- GRID VIEW -->
|
<!-- 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-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 -->
|
<!-- Thumbnail -->
|
||||||
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
|
<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" />
|
<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 -->
|
<!-- Body -->
|
||||||
<div class="p-5 flex flex-col flex-1">
|
<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'">
|
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
|
||||||
{{ course.formatted_price }}
|
{{ course.formatted_price }}
|
||||||
</div>
|
</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-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">
|
||||||
<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">
|
|
||||||
<q-icon name="visibility" size="18px" />
|
<q-icon name="visibility" size="18px" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -259,7 +258,7 @@ onMounted(async () => {
|
||||||
|
|
||||||
<!-- LIST VIEW -->
|
<!-- LIST VIEW -->
|
||||||
<div v-else class="flex flex-col gap-5">
|
<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">
|
<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" />
|
<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;">
|
<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>
|
||||||
<div class="flex flex-col flex-1 py-1">
|
<div class="flex flex-col flex-1 py-1">
|
||||||
<div class="flex-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>
|
||||||
<div class="mt-4 sm:mt-auto flex items-center justify-between">
|
<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'">
|
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
|
||||||
{{ course.formatted_price }}
|
{{ course.formatted_price }}
|
||||||
</div>
|
</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">
|
<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" /> ดูรายละเอียด
|
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -290,9 +289,9 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- 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" />
|
<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>
|
<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';">
|
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
|
||||||
{{ $t("discovery.showAll") }}
|
{{ $t("discovery.showAll") }}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ useHead({
|
||||||
title: 'คอร์สทั้งหมด - E-Learning System'
|
title: 'คอร์สทั้งหมด - E-Learning System'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const { fetchCourses } = useCourse()
|
const { fetchCourses } = useCourse()
|
||||||
const { fetchCategories, categories } = useCategory()
|
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">
|
<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="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">
|
<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]" />
|
<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>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<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 = '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')"
|
@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="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">
|
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>
|
||||||
|
|
||||||
<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>
|
<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">
|
<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>
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5 mb-5">
|
<div class="flex items-center gap-1.5 mb-5">
|
||||||
<q-icon name="star" class="text-amber-400" size="16px" />
|
<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-[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-auto flex items-center justify-between">
|
<div class="mt-auto flex items-center justify-between">
|
||||||
|
|
@ -222,12 +223,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
<div class="flex-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 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
|
<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">
|
<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>
|
||||||
<div class="flex items-center gap-1.5 mb-2">
|
<div class="flex items-center gap-1.5 mb-2">
|
||||||
<q-icon name="star" class="text-amber-400" size="16px" />
|
<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-[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>
|
</div>
|
||||||
<div class="mt-4 sm:mt-auto flex items-center justify-between">
|
<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 }}
|
{{ course.formatted_price }}
|
||||||
</div>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -246,10 +247,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
|
||||||
<!-- กรณีไม่พบข้อมูลคอร์ส (Empty State) -->
|
<!-- กรณีไม่พบข้อมูลคอร์ส (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/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" />
|
<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>
|
<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">ลองใช้คำค้นหาอื่น หรือเลือกหมวดหมู่อื่นเพื่อดูคอร์สที่เรามีให้บริการ</p>
|
<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';">
|
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
|
||||||
แสดงคอร์สทั้งหมด
|
{{ $t('discovery.showAll') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -343,10 +343,12 @@ const save = async () => {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
// Convert local datetime to ISO string to preserve timezone
|
// Convert local datetime to ISO string to preserve timezone
|
||||||
const payload = { ...form.value };
|
const payload: any = { ...form.value };
|
||||||
if (payload.published_at) {
|
if (payload.published_at) {
|
||||||
const localDate = new Date(payload.published_at.replace(' ', 'T'));
|
const localDate = new Date(payload.published_at.replace(' ', 'T'));
|
||||||
payload.published_at = localDate.toISOString();
|
payload.published_at = localDate.toISOString();
|
||||||
|
} else {
|
||||||
|
delete payload.published_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editing.value) {
|
if (editing.value) {
|
||||||
|
|
@ -447,10 +449,7 @@ const deleteAttachment = async (attachmentId: number) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes < 1024) return bytes + ' B';
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
v-for="item in history"
|
v-for="item in history"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="titleMap[item.action] || item.action"
|
:title="titleMap[item.action] || item.action"
|
||||||
:subtitle="formatDate(item.created_at)"
|
:subtitle="formatDateTime(item.created_at)"
|
||||||
:color="colorMap[item.action] || 'grey'"
|
:color="colorMap[item.action] || 'grey'"
|
||||||
:icon="iconMap[item.action] || 'circle'"
|
:icon="iconMap[item.action] || 'circle'"
|
||||||
>
|
>
|
||||||
|
|
@ -91,12 +91,7 @@ const getActorName = (item: ApprovalHistory) => {
|
||||||
return actor.username || actor.email || 'Unknown User';
|
return actor.username || actor.email || 'Unknown User';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(dateString).toLocaleString('th-TH', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
|
|
|
||||||
|
|
@ -450,14 +450,7 @@ const openStudentDetail = async (studentId: number) => {
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
const date = new Date(dateStr);
|
return formatDateTime(dateStr);
|
||||||
return date.toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|
|
||||||
|
|
@ -404,8 +404,7 @@ const getStudentStatusLabel = (status: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatEnrollDate = (dateStr: string) => {
|
const formatEnrollDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr);
|
return formatDate(dateStr);
|
||||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLessonTypeIcon = (type: string) => {
|
const getLessonTypeIcon = (type: string) => {
|
||||||
|
|
@ -436,8 +435,7 @@ const formatVideoTime = (seconds: number) => {
|
||||||
|
|
||||||
const formatCompletedDate = (dateStr: string | null) => {
|
const formatCompletedDate = (dateStr: string | null) => {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
const date = new Date(dateStr);
|
return formatDate(dateStr);
|
||||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch on mount
|
// Fetch on mount
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
<!-- Created At Custom Column -->
|
<!-- Created At Custom Column -->
|
||||||
<template v-slot:body-cell-created_at="props">
|
<template v-slot:body-cell-created_at="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
{{ formatDate(props.value) }}
|
{{ formatDateTime(props.value) }}
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-subtitle2 text-grey">Date & Time</div>
|
<div class="text-subtitle2 text-grey">Date & Time</div>
|
||||||
<div>{{ formatDate(selectedLog.created_at) }}</div>
|
<div>{{ formatDateTime(selectedLog.created_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -241,7 +241,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar, type QTableColumn } from 'quasar';
|
||||||
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
|
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -284,15 +284,15 @@ const pagination = ref({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Table setup
|
// Table setup
|
||||||
const columns = [
|
const columns: QTableColumn[] = [
|
||||||
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
|
{ name: 'id', label: 'ID', field: 'id', align: 'left' as const, style: 'width: 60px' },
|
||||||
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
|
{ name: 'action', label: 'Action', field: 'action', align: 'left' as const },
|
||||||
{ name: 'user', label: 'User', field: 'user', align: 'left' },
|
{ name: 'user', label: 'User', field: 'user', align: 'left' as const },
|
||||||
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
|
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' as const },
|
||||||
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
|
{ 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: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' as const },
|
||||||
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
{ name: 'actions', label: '', field: 'actions', align: 'center' as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Actions options (for filtering)
|
// Actions options (for filtering)
|
||||||
|
|
@ -416,10 +416,7 @@ const tryFormatJson = (str: string | null) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
if (!date) return '-';
|
|
||||||
return new Date(date).toLocaleString('th-TH');
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_COLOR_MAP: Record<string, string> = {
|
const ACTION_COLOR_MAP: Record<string, string> = {
|
||||||
DELETE: 'negative',
|
DELETE: 'negative',
|
||||||
|
|
@ -453,10 +450,12 @@ onMounted(() => {
|
||||||
:deep(input[type=number]::-webkit-outer-spin-button),
|
:deep(input[type=number]::-webkit-outer-spin-button),
|
||||||
:deep(input[type=number]::-webkit-inner-spin-button) {
|
:deep(input[type=number]::-webkit-inner-spin-button) {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(input[type=number]) {
|
:deep(input[type=number]) {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -233,13 +233,7 @@ const fetchCategories = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.value = {
|
form.value = {
|
||||||
|
|
|
||||||
|
|
@ -356,23 +356,7 @@ const getActionColor = (action: string) => {
|
||||||
return colors[action] || 'grey';
|
return colors[action] || 'grey';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting functions are auto-imported from utils/date.ts
|
||||||
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'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmApprove = () => {
|
const confirmApprove = () => {
|
||||||
if (!course.value) return;
|
if (!course.value) return;
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@
|
||||||
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
|
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
|
||||||
<q-icon name="send" size="16px" class="mr-1" />
|
<q-icon name="send" size="16px" class="mr-1" />
|
||||||
ส่งโดย {{ course.latest_submission.submitter.username }}
|
ส่งโดย {{ course.latest_submission.submitter.username }}
|
||||||
เมื่อ {{ formatDate(course.latest_submission.created_at) }}
|
เมื่อ {{ formatDateTime(course.latest_submission.created_at) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@
|
||||||
<template v-slot:body-cell-submitted_at="props">
|
<template v-slot:body-cell-submitted_at="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<div v-if="props.row.latest_submission">
|
<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 class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
|
|
@ -298,15 +298,7 @@ const getPrimaryInstructor = (course: PendingCourse) => {
|
||||||
return primary?.user.username || course.creator.username;
|
return primary?.user.username || course.creator.username;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewCourse = (course: PendingCourse) => {
|
const viewCourse = (course: PendingCourse) => {
|
||||||
router.push(`/admin/courses/${course.id}`);
|
router.push(`/admin/courses/${course.id}`);
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
|
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-400 whitespace-nowrap">
|
<div class="text-xs text-gray-400 whitespace-nowrap">
|
||||||
{{ formatDate(course.created_at) }}
|
{{ formatDateStr(course.created_at) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
<span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span>
|
<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>
|
<span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,14 +254,7 @@ const fetchDashboardData = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
const formatDate = (date: string) => {
|
const formatDateStr = (date: string) => formatDateTime(date);
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActionIcon = (action: string) => {
|
const getActionIcon = (action: string) => {
|
||||||
if (action.includes('create')) return 'add_circle';
|
if (action.includes('create')) return 'add_circle';
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { userService, type UserProfileResponse } from '~/services/user.service';
|
import { userService } from '~/services/user.service';
|
||||||
import { authService } from '~/services/auth.service';
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
|
||||||
return labels[role] || role;
|
return labels[role] || role;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string, includeTime = true) => {
|
// Use formatting utilities from utils/date.ts
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
// Format functions are auto-imported
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Avatar upload
|
// Avatar upload
|
||||||
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
|
||||||
try {
|
try {
|
||||||
const response = await userService.uploadAvatar(file);
|
const response = await userService.uploadAvatar(file);
|
||||||
|
|
||||||
// Re-fetch profile to get presigned URL from backend
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
|
||||||
phone: editForm.value.phone || null
|
phone: editForm.value.phone || null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh profile data from API
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -546,25 +534,29 @@ watch(showEditModal, (newVal) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch profile from API
|
// Helper to map fullProfile to local profile state
|
||||||
const fetchProfile = async () => {
|
const mapProfileData = (data: typeof authStore.fullProfile) => {
|
||||||
|
if (!data) return;
|
||||||
|
profile.value = {
|
||||||
|
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
||||||
|
email: data.email,
|
||||||
|
emailVerified: !!data.email_verified_at,
|
||||||
|
username: data.username,
|
||||||
|
phone: data.profile.phone || '',
|
||||||
|
role: data.role.code,
|
||||||
|
roleName: data.role.name.th,
|
||||||
|
avatar: '',
|
||||||
|
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;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await userService.getProfile();
|
await authStore.fetchUserProfile(force);
|
||||||
|
mapProfileData(authStore.fullProfile);
|
||||||
// Map API response to profile
|
|
||||||
profile.value = {
|
|
||||||
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
|
||||||
email: data.email,
|
|
||||||
emailVerified: !!data.email_verified_at,
|
|
||||||
username: data.username,
|
|
||||||
phone: data.profile.phone || '',
|
|
||||||
role: data.role.code,
|
|
||||||
roleName: data.role.name.th,
|
|
||||||
avatar: '',
|
|
||||||
avatarUrl: data.profile.avatar_url,
|
|
||||||
createdAt: data.created_at
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
|
|
@ -576,7 +568,7 @@ const fetchProfile = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load profile on mount
|
// Load profile on mount (uses cache if available)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -324,13 +324,7 @@ const getRoleBadgeColor = (roleCode: string) => {
|
||||||
return colors[roleCode] || 'grey';
|
return colors[roleCode] || 'grey';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewUser = (user: AdminUserResponse) => {
|
const viewUser = (user: AdminUserResponse) => {
|
||||||
selectedUser.value = user;
|
selectedUser.value = user;
|
||||||
|
|
|
||||||
|
|
@ -449,13 +449,7 @@ const getStatusLabel = (status: string) => {
|
||||||
return labels[status] || status;
|
return labels[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
// Date formatting function is auto-imported from utils/date.ts
|
||||||
return new Date(date).toLocaleDateString('th-TH', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// Clone Dialog
|
// Clone Dialog
|
||||||
const cloneDialog = ref(false);
|
const cloneDialog = ref(false);
|
||||||
const cloneLoading = ref(false);
|
const cloneLoading = ref(false);
|
||||||
|
|
|
||||||
|
|
@ -64,21 +64,21 @@
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<q-card class="p-6 text-center">
|
<q-card class="p-6 text-center">
|
||||||
<div class="text-4xl font-bold text-primary-600 mb-2">
|
<div class="text-4xl font-bold text-primary-600 mb-2">
|
||||||
{{ instructorStore.stats.totalCourses }}
|
{{ stats.totalCourses }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">หลักสูตรทั้งหมด</div>
|
<div class="text-gray-600">หลักสูตรทั้งหมด</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<q-card class="p-6 text-center">
|
<q-card class="p-6 text-center">
|
||||||
<div class="text-4xl font-bold text-secondary-600 mb-2">
|
<div class="text-4xl font-bold text-secondary-600 mb-2">
|
||||||
{{ instructorStore.stats.totalStudents }}
|
{{ stats.totalStudents }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">ผู้เรียนทั้งหมด</div>
|
<div class="text-gray-600">ผู้เรียนทั้งหมด</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<q-card class="p-6 text-center">
|
<q-card class="p-6 text-center">
|
||||||
<div class="text-4xl font-bold text-accent-600 mb-2">
|
<div class="text-4xl font-bold text-accent-600 mb-2">
|
||||||
{{ instructorStore.stats.completedStudents }}
|
{{ stats.completedStudents }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">เรียนจบแล้ว</div>
|
<div class="text-gray-600">เรียนจบแล้ว</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -96,28 +96,28 @@
|
||||||
<q-icon name="check_circle" color="green" size="24px" />
|
<q-icon name="check_circle" color="green" size="24px" />
|
||||||
<span class="font-medium text-gray-700">เผยแพร่แล้ว</span>
|
<span class="font-medium text-gray-700">เผยแพร่แล้ว</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<q-icon name="hourglass_empty" color="orange" size="24px" />
|
<q-icon name="hourglass_empty" color="orange" size="24px" />
|
||||||
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
|
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<q-icon name="edit_note" color="grey" size="24px" />
|
<q-icon name="edit_note" color="grey" size="24px" />
|
||||||
<span class="font-medium text-gray-700">แบบร่าง</span>
|
<span class="font-medium text-gray-700">แบบร่าง</span>
|
||||||
</div>
|
</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>
|
||||||
<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">
|
<div class="flex items-center gap-3">
|
||||||
<q-icon name="cancel" color="red" size="24px" />
|
<q-icon name="cancel" color="red" size="24px" />
|
||||||
<span class="font-medium text-gray-700">ถูกปฏิเสธ</span>
|
<span class="font-medium text-gray-700">ถูกปฏิเสธ</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<q-card
|
<q-card
|
||||||
v-for="course in instructorStore.recentCourses"
|
v-for="course in recentCourses"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
class="cursor-pointer hover:shadow-md transition"
|
class="cursor-pointer hover:shadow-md transition"
|
||||||
@click="router.push(`/instructor/courses/${course.id}`)"
|
@click="router.push(`/instructor/courses/${course.id}`)"
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
import { instructorService } from '~/services/instructor.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'instructor',
|
layout: 'instructor',
|
||||||
|
|
@ -179,10 +180,32 @@ definePageMeta({
|
||||||
});
|
});
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const instructorStore = useInstructorStore();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const $q = useQuasar();
|
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
|
// Navigation functions
|
||||||
const goToProfile = () => {
|
const goToProfile = () => {
|
||||||
router.push('/instructor/profile');
|
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(() => {
|
onMounted(() => {
|
||||||
instructorStore.fetchDashboardData();
|
|
||||||
authStore.fetchUserProfile();
|
authStore.fetchUserProfile();
|
||||||
|
fetchDashboardData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { userService, type UserProfileResponse } from '~/services/user.service';
|
import { userService } from '~/services/user.service';
|
||||||
import { authService } from '~/services/auth.service';
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -368,20 +368,8 @@ const getRoleLabel = (role: string) => {
|
||||||
return labels[role] || role;
|
return labels[role] || role;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string, includeTime = true) => {
|
// Use formatting utilities from utils/date.ts
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
// Format functions are auto-imported
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Avatar upload
|
// Avatar upload
|
||||||
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
@ -425,8 +413,8 @@ const handleAvatarUpload = async (event: Event) => {
|
||||||
try {
|
try {
|
||||||
const response = await userService.uploadAvatar(file);
|
const response = await userService.uploadAvatar(file);
|
||||||
|
|
||||||
// Re-fetch profile to get presigned URL from backend
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -457,8 +445,8 @@ const handleUpdateProfile = async () => {
|
||||||
phone: editForm.value.phone || null
|
phone: editForm.value.phone || null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh profile data from API
|
// Force refresh profile cache and update local state
|
||||||
await fetchProfile();
|
await fetchProfile(true);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -546,25 +534,29 @@ watch(showEditModal, (newVal) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch profile from API
|
// Helper to map fullProfile to local profile state
|
||||||
const fetchProfile = async () => {
|
const mapProfileData = (data: typeof authStore.fullProfile) => {
|
||||||
|
if (!data) return;
|
||||||
|
profile.value = {
|
||||||
|
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
||||||
|
email: data.email,
|
||||||
|
emailVerified: !!data.email_verified_at,
|
||||||
|
username: data.username,
|
||||||
|
phone: data.profile.phone || '',
|
||||||
|
role: data.role.code,
|
||||||
|
roleName: data.role.name.th,
|
||||||
|
avatar: '',
|
||||||
|
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;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await userService.getProfile();
|
await authStore.fetchUserProfile(force);
|
||||||
|
mapProfileData(authStore.fullProfile);
|
||||||
// Map API response to profile
|
|
||||||
profile.value = {
|
|
||||||
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
|
||||||
email: data.email,
|
|
||||||
emailVerified: !!data.email_verified_at,
|
|
||||||
username: data.username,
|
|
||||||
phone: data.profile.phone || '',
|
|
||||||
role: data.role.code,
|
|
||||||
roleName: data.role.name.th,
|
|
||||||
avatar: '',
|
|
||||||
avatarUrl: data.profile.avatar_url,
|
|
||||||
createdAt: data.created_at
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
|
|
@ -576,7 +568,7 @@ const fetchProfile = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load profile on mount
|
// Load profile on mount (uses cache if available)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,11 @@ export default defineConfig({
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
|
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
|
||||||
headless: false, // false = เห็น browser ขณะรัน
|
headless: false, // false = เห็น browser ขณะรัน
|
||||||
screenshot: 'only-on-failure', // เก็บ screenshot เมื่อ fail
|
screenshot: 'on', // เก็บ screenshot
|
||||||
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
|
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
|
||||||
// launchOptions: {
|
launchOptions: {
|
||||||
// slowMo: 1000,
|
slowMo: 500,
|
||||||
// }, // ช้าลง 10 วินาที
|
}, // ช้าลง 10 วินาที
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */
|
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */
|
||||||
|
|
|
||||||
|
|
@ -610,6 +610,19 @@ export const instructorService = {
|
||||||
{ method: 'DELETE' }
|
{ 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[]> {
|
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
|
||||||
const response = await authRequest<{
|
const response = await authRequest<{
|
||||||
code: number;
|
code: number;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { authService } from '~/services/auth.service';
|
import { authService } from '~/services/auth.service';
|
||||||
import { userService } from '~/services/user.service';
|
import { userService, type UserProfileResponse } from '~/services/user.service';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -15,7 +15,8 @@ export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
user: null as User | null,
|
user: null as User | null,
|
||||||
token: null as string | null,
|
token: null as string | null,
|
||||||
isAuthenticated: false
|
isAuthenticated: false,
|
||||||
|
fullProfile: null as UserProfileResponse | null
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
|
|
@ -61,6 +62,7 @@ export const useAuthStore = defineStore('auth', {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.token = null;
|
this.token = null;
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
|
this.fullProfile = null;
|
||||||
|
|
||||||
// Clear cookies
|
// Clear cookies
|
||||||
const tokenCookie = useCookie('token');
|
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 {
|
try {
|
||||||
const response = await userService.getProfile();
|
const response = await userService.getProfile();
|
||||||
|
|
||||||
|
// Cache raw API response
|
||||||
|
this.fullProfile = response;
|
||||||
|
|
||||||
// Update local user state
|
// Update local user state
|
||||||
this.user = {
|
this.user = {
|
||||||
id: response.id.toString(),
|
id: response.id.toString(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 213 KiB |