feat: Implement initial frontend for admin and instructor roles, including dashboards, course management, authentication, and core services.

This commit is contained in:
Missez 2026-02-06 14:58:41 +07:00
parent 832a8f5067
commit 127b63de49
16 changed files with 1505 additions and 102 deletions

View file

@ -15,9 +15,15 @@
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'ADMIN' }}</div>
</div>
<div
class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition"
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"
>
<q-icon name="person" />
<img
v-if="authStore.user?.avatarUrl"
:src="authStore.user.avatarUrl"
alt=""
class="w-full h-full object-cover"
/>
<q-icon v-else name="person" />
<q-menu>
<q-list style="min-width: 200px">
<!-- User Info Header -->
@ -56,52 +62,127 @@
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card">
<div class="card bg-white p-6 rounded-lg shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">หลกสตรทงหมด</p>
<p class="text-3xl font-bold text-primary-600">5</p>
<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" />
</div>
<q-icon name="school" size="48px" class="text-primary-200" />
</div>
</div>
<div class="card">
<div class="card bg-white p-6 rounded-lg shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">เรยนทงหมด</p>
<p class="text-3xl font-bold text-secondary-500">125</p>
<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" />
</div>
<q-icon name="people" size="48px" class="text-secondary-200" />
</div>
</div>
<div class="card">
<div class="card bg-white p-6 rounded-lg shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">เรยนจบแล</p>
<p class="text-3xl font-bold text-accent-500">45</p>
<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" />
</div>
</div>
</div>
</div>
<!-- Recent Courses -->
<div class="card">
<h2 class="text-xl font-semibold mb-4">Dashboard</h2>
<!-- <div class="space-y-4">
<div class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-lg flex items-center justify-center">
<q-icon name="code" size="32px" class="text-primary-600" />
</div>
<div class="flex-1">
<h3 class="font-semibold">Python เบองต</h3>
<p class="text-sm text-gray-600">45 เรยน 8 บทเรยน</p>
</div>
<q-btn flat color="primary" label="ดูรายละเอียด" />
<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" />
</div>
</div> -->
<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>
<q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/audit-logs" size="sm" />
</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>
</div>
</div>
</template>
<script setup lang="ts">
import { adminService, type PendingCourse, type AuditLog } from '~/services/admin.service';
definePageMeta({
layout: 'admin',
middleware: 'auth'
@ -110,6 +191,16 @@ definePageMeta({
const authStore = useAuthStore();
const router = useRouter();
// State
const loading = ref(true);
const pendingCourses = ref<PendingCourse[]>([]);
const recentLogs = ref<AuditLog[]>([]);
const totalUsers = ref(0);
const stats = ref({
todayLogs: 0,
totalLogs: 0
});
// Navigation functions
const goToProfile = () => {
router.push('/admin/profile');
@ -119,4 +210,67 @@ const handleLogout = () => {
authStore.logout();
router.push('/login');
};
// 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();
});
</script>