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", "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",

View file

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

View file

@ -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>

View file

@ -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';

View file

@ -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();

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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 = {

View file

@ -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;

View file

@ -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}`);

View file

@ -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';

View file

@ -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();
}); });

View file

@ -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;

View file

@ -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);

View file

@ -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>

View file

@ -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();
}); });

View file

@ -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 ──── */

View file

@ -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;

View file

@ -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(),

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