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

This commit is contained in:
Missez 2026-03-06 17:33:01 +07:00
parent ea442d7815
commit ae32cfebe4
17 changed files with 199 additions and 275 deletions

View file

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

@ -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,89 +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 courses and student stats in parallel
const [courses, studentStats] = await Promise.all([
instructorService.getCourses(),
instructorService.getMyStudentsStats()
]);
// Update student stats from dedicated API
this.stats.totalCourses = courses.length;
this.stats.totalStudents = studentStats.total_students;
this.stats.completedStudents = studentStats.total_completed;
// 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
};
// Build recent courses list (first 3) from existing data
this.recentCourses = 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);
} finally {
this.loading = false;
}
}
}
});

View file

@ -0,0 +1,33 @@
/**
* Format a date string into Thai locale format (Date only)
* Example: 10 .. 67
*/
export const formatDate = (date: string | Date | null | undefined): string => {
if (!date) return '-';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
/**
* Format a date string into Thai locale format (Date and Time)
* Example: 10 .. 67 14:30
*/
export const formatDateTime = (date: string | Date | null | undefined): string => {
if (!date) return '-';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};