2026-01-12 16:49:58 +07:00
|
|
|
<template>
|
|
|
|
|
<div>
|
2026-01-14 13:58:25 +07:00
|
|
|
<!-- Header -->
|
|
|
|
|
<div class="mb-8">
|
|
|
|
|
<div class="flex justify-between items-center">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 class="text-2xl font-bold text-gray-900">
|
2026-02-02 09:31:22 +07:00
|
|
|
สวัสดี, {{ authStore.user?.firstName }} {{ authStore.user?.lastName }}
|
2026-01-14 13:58:25 +07:00
|
|
|
</h1>
|
|
|
|
|
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-center gap-4">
|
|
|
|
|
<div class="text-right">
|
2026-02-02 09:31:22 +07:00
|
|
|
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.firstName }} {{ authStore.user?.lastName }}</div>
|
2026-01-14 13:58:25 +07:00
|
|
|
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'ADMIN' }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
2026-02-06 14:58:41 +07:00
|
|
|
class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition overflow-hidden"
|
2026-01-14 13:58:25 +07:00
|
|
|
>
|
2026-02-06 14:58:41 +07:00
|
|
|
<img
|
|
|
|
|
v-if="authStore.user?.avatarUrl"
|
|
|
|
|
:src="authStore.user.avatarUrl"
|
|
|
|
|
alt=""
|
|
|
|
|
class="w-full h-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
<q-icon v-else name="person" />
|
2026-01-14 13:58:25 +07:00
|
|
|
<q-menu>
|
|
|
|
|
<q-list style="min-width: 200px">
|
|
|
|
|
<!-- User Info Header -->
|
|
|
|
|
<q-item class="bg-primary-50">
|
|
|
|
|
<q-item-section>
|
2026-02-02 09:31:22 +07:00
|
|
|
<q-item-label class="text-weight-bold">{{ authStore.user?.firstName }} {{ authStore.user?.lastName }}</q-item-label>
|
2026-01-14 13:58:25 +07:00
|
|
|
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
|
|
|
|
|
</q-item-section>
|
|
|
|
|
</q-item>
|
|
|
|
|
|
|
|
|
|
<q-separator />
|
|
|
|
|
|
|
|
|
|
<!-- Profile -->
|
|
|
|
|
<q-item clickable v-close-popup @click="goToProfile">
|
|
|
|
|
<q-item-section avatar>
|
|
|
|
|
<q-icon name="person" />
|
|
|
|
|
</q-item-section>
|
|
|
|
|
<q-item-section>โปรไฟล์</q-item-section>
|
|
|
|
|
</q-item>
|
|
|
|
|
|
|
|
|
|
<q-separator />
|
|
|
|
|
|
|
|
|
|
<!-- Logout -->
|
|
|
|
|
<q-item clickable v-close-popup @click="handleLogout">
|
|
|
|
|
<q-item-section avatar>
|
|
|
|
|
<q-icon name="logout" color="negative" />
|
|
|
|
|
</q-item-section>
|
|
|
|
|
<q-item-section class="text-negative">ออกจากระบบ</q-item-section>
|
|
|
|
|
</q-item>
|
|
|
|
|
</q-list>
|
|
|
|
|
</q-menu>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-12 16:49:58 +07:00
|
|
|
<!-- Stats Cards -->
|
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
2026-02-06 14:58:41 +07:00
|
|
|
<div class="card bg-white p-6 rounded-lg shadow-sm">
|
2026-01-12 16:49:58 +07:00
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<div>
|
2026-02-06 14:58:41 +07:00
|
|
|
<p class="text-gray-600 text-sm">คอร์สรออนุมัติ</p>
|
|
|
|
|
<p class="text-3xl font-bold text-orange-600">{{ pendingCourses.length }}</p>
|
|
|
|
|
<p class="text-xs text-gray-500 mt-1">จากทั้งหมด {{ pendingCourses.length }} รายการ</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
|
|
|
|
<q-icon name="pending_actions" size="28px" class="text-orange-600" />
|
2026-01-12 16:49:58 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-06 14:58:41 +07:00
|
|
|
|
|
|
|
|
<div class="card bg-white p-6 rounded-lg shadow-sm">
|
2026-01-12 16:49:58 +07:00
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<div>
|
2026-02-06 14:58:41 +07:00
|
|
|
<p class="text-gray-600 text-sm">กิจกรรมวันนี้</p>
|
|
|
|
|
<p class="text-3xl font-bold text-primary-600">{{ stats.todayLogs }}</p>
|
|
|
|
|
<p class="text-xs text-gray-500 mt-1">Logs ทั้งหมดวันนี้</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
|
|
|
|
|
<q-icon name="today" size="28px" class="text-primary-600" />
|
2026-01-12 16:49:58 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-06 14:58:41 +07:00
|
|
|
|
|
|
|
|
<div class="card bg-white p-6 rounded-lg shadow-sm">
|
2026-01-12 16:49:58 +07:00
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
<div>
|
2026-02-06 14:58:41 +07:00
|
|
|
<p class="text-gray-600 text-sm">ผู้ใช้งานทั้งหมด</p>
|
|
|
|
|
<p class="text-3xl font-bold text-secondary-600">{{ totalUsers }}</p>
|
|
|
|
|
<p class="text-xs text-gray-500 mt-1">ในระบบปัจจุบัน</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="w-12 h-12 bg-secondary-100 rounded-lg flex items-center justify-center">
|
|
|
|
|
<q-icon name="people" size="28px" class="text-secondary-600" />
|
2026-01-12 16:49:58 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-06 14:58:41 +07:00
|
|
|
|
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
|
<!-- Pending Courses List -->
|
|
|
|
|
<div class="card bg-white rounded-lg shadow-sm">
|
|
|
|
|
<div class="p-6 border-b flex justify-between items-center">
|
|
|
|
|
<h2 class="text-lg font-semibold text-gray-900">คอร์สรอตรวจสอบล่าสุด</h2>
|
|
|
|
|
<q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/courses/pending" size="sm" />
|
2026-01-12 16:49:58 +07:00
|
|
|
</div>
|
2026-02-06 14:58:41 +07:00
|
|
|
<div class="divide-y">
|
|
|
|
|
<div v-if="loading" class="p-8 text-center text-gray-500">
|
|
|
|
|
<q-spinner color="primary" size="2em" />
|
|
|
|
|
<p class="mt-2">กำลังโหลด...</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else-if="pendingCourses.length === 0" class="p-8 text-center text-gray-500">
|
|
|
|
|
<q-icon name="check_circle" size="48px" color="green-2" class="mb-2" />
|
|
|
|
|
<p>ไม่มีคอร์สรอตรวจสอบ</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-else
|
|
|
|
|
v-for="course in pendingCourses.slice(0, 5)"
|
|
|
|
|
:key="course.id"
|
|
|
|
|
class="p-4 flex items-center gap-4 hover:bg-gray-50 transition cursor-pointer"
|
|
|
|
|
@click="router.push(`/admin/courses/${course.id}`)"
|
|
|
|
|
>
|
|
|
|
|
<div class="w-12 h-12 bg-gray-200 rounded-lg flex-shrink-0 overflow-hidden">
|
|
|
|
|
<img v-if="course.thumbnail_url" :src="course.thumbnail_url" class="w-full h-full object-cover" />
|
|
|
|
|
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
|
|
|
|
<q-icon name="image" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
<h3 class="text-sm font-medium text-gray-900 truncate">{{ course.title.th }}</h3>
|
|
|
|
|
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-xs text-gray-400 whitespace-nowrap">
|
|
|
|
|
{{ formatDate(course.created_at) }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Recent Activity List -->
|
|
|
|
|
<div class="card bg-white rounded-lg shadow-sm">
|
|
|
|
|
<div class="p-6 border-b flex justify-between items-center">
|
|
|
|
|
<h2 class="text-lg font-semibold text-gray-900">กิจกรรมล่าสุด</h2>
|
2026-02-24 09:25:02 +07:00
|
|
|
<q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-log" size="sm" />
|
2026-02-06 14:58:41 +07:00
|
|
|
</div>
|
|
|
|
|
<div class="divide-y">
|
|
|
|
|
<div v-if="loading" class="p-8 text-center text-gray-500">
|
|
|
|
|
<q-spinner color="primary" size="2em" />
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else-if="recentLogs.length === 0" class="p-8 text-center text-gray-500">
|
|
|
|
|
<p>ไม่มีกิจกรรมล่าสุด</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-else
|
|
|
|
|
v-for="log in recentLogs.slice(0, 5)"
|
|
|
|
|
:key="log.id"
|
|
|
|
|
class="p-4 flex items-start gap-3 text-sm"
|
|
|
|
|
>
|
|
|
|
|
<div class="mt-0.5">
|
|
|
|
|
<q-icon :name="getActionIcon(log.action)" :color="getActionColor(log.action)" size="20px" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
<p class="text-gray-900">
|
|
|
|
|
<span class="font-medium">{{ log.user?.username || 'Unknown' }}</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>
|
|
|
|
|
</p>
|
|
|
|
|
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(log.created_at) }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-12 16:49:58 +07:00
|
|
|
</div>
|
2026-02-06 14:58:41 +07:00
|
|
|
|
2026-01-12 16:49:58 +07:00
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-02-06 14:58:41 +07:00
|
|
|
|
2026-01-12 16:49:58 +07:00
|
|
|
<script setup lang="ts">
|
2026-02-06 14:58:41 +07:00
|
|
|
import { adminService, type PendingCourse, type AuditLog } from '~/services/admin.service';
|
|
|
|
|
|
2026-01-12 16:49:58 +07:00
|
|
|
definePageMeta({
|
2026-01-14 13:58:25 +07:00
|
|
|
layout: 'admin',
|
|
|
|
|
middleware: 'auth'
|
2026-01-12 16:49:58 +07:00
|
|
|
});
|
2026-01-14 13:58:25 +07:00
|
|
|
|
2026-01-12 16:49:58 +07:00
|
|
|
const authStore = useAuthStore();
|
2026-01-14 13:58:25 +07:00
|
|
|
const router = useRouter();
|
|
|
|
|
|
2026-02-06 14:58:41 +07:00
|
|
|
// State
|
|
|
|
|
const loading = ref(true);
|
|
|
|
|
const pendingCourses = ref<PendingCourse[]>([]);
|
|
|
|
|
const recentLogs = ref<AuditLog[]>([]);
|
|
|
|
|
const totalUsers = ref(0);
|
|
|
|
|
const stats = ref({
|
|
|
|
|
todayLogs: 0,
|
|
|
|
|
totalLogs: 0
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-14 13:58:25 +07:00
|
|
|
// Navigation functions
|
|
|
|
|
const goToProfile = () => {
|
|
|
|
|
router.push('/admin/profile');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLogout = () => {
|
|
|
|
|
authStore.logout();
|
|
|
|
|
router.push('/login');
|
|
|
|
|
};
|
2026-02-06 14:58:41 +07:00
|
|
|
|
|
|
|
|
// Data Fetching
|
|
|
|
|
const fetchDashboardData = async () => {
|
|
|
|
|
loading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const [pending, logStats, users] = await Promise.all([
|
|
|
|
|
adminService.getPendingCourses(),
|
|
|
|
|
adminService.getAuditLogStats(),
|
|
|
|
|
adminService.getUsers()
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
pendingCourses.value = pending;
|
|
|
|
|
stats.value = logStats;
|
|
|
|
|
recentLogs.value = logStats.recentActivity;
|
|
|
|
|
|
|
|
|
|
// This might be heavy if lots of users, practically simpler analytics endpoint is better
|
|
|
|
|
// but for now this works as requested to use existing API
|
|
|
|
|
totalUsers.value = users.length;
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch dashboard data:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Utilities
|
|
|
|
|
const formatDate = (date: string) => {
|
|
|
|
|
return new Date(date).toLocaleDateString('th-TH', {
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit'
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getActionIcon = (action: string) => {
|
|
|
|
|
if (action.includes('create')) return 'add_circle';
|
|
|
|
|
if (action.includes('update')) return 'edit';
|
|
|
|
|
if (action.includes('delete')) return 'delete';
|
|
|
|
|
if (action.includes('login')) return 'login';
|
|
|
|
|
if (action.includes('approve')) return 'check_circle';
|
|
|
|
|
if (action.includes('reject')) return 'cancel';
|
|
|
|
|
return 'info';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getActionColor = (action: string) => {
|
|
|
|
|
action = action.toLowerCase();
|
|
|
|
|
if (action.includes('delete') || action.includes('reject')) return 'negative';
|
|
|
|
|
if (action.includes('update')) return 'warning';
|
|
|
|
|
if (action.includes('approve') || action.includes('create')) return 'positive';
|
|
|
|
|
return 'grey';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatAction = (action: string) => {
|
|
|
|
|
return action.toLowerCase().replace(/_/g, ' ');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
authStore.fetchUserProfile();
|
|
|
|
|
fetchDashboardData();
|
|
|
|
|
});
|
2026-01-12 16:49:58 +07:00
|
|
|
</script>
|