feat: Implement initial frontend for admin and instructor roles, including dashboards, course management, authentication, and core services.
This commit is contained in:
parent
832a8f5067
commit
127b63de49
16 changed files with 1505 additions and 102 deletions
|
|
@ -29,7 +29,7 @@
|
|||
Chapter {{ chapter.sort_order }}: {{ chapter.title.th }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{{ chapter.lessons.length }} บทเรียน · {{ getChapterDuration(chapter) }} นาที
|
||||
{{ chapter.lessons.length }} บทเรียน
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -53,9 +53,6 @@
|
|||
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<span class="text-sm text-gray-500">{{ lesson.duration_minutes }} นาที</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
|
|
|
|||
|
|
@ -54,24 +54,24 @@
|
|||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="/admin/settings"
|
||||
to="/admin/audit-logs"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||
>
|
||||
<q-icon name="settings" size="24px" />
|
||||
<span>ตั้งค่าระบบ</span>
|
||||
<span>Audit logs</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- <div class="absolute bottom-0 left-0 right-0 p-4 border-t">
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 border-t">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
>
|
||||
<span>🚪</span>
|
||||
<q-icon name="logout" size="24px" />
|
||||
<span>ออกจากระบบ</span>
|
||||
</button>
|
||||
</div> -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
|
|
|
|||
|
|
@ -27,15 +27,15 @@
|
|||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- <div class="absolute bottom-0 left-0 right-0 p-4 border-t">
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 border-t">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
>
|
||||
<span>🚪</span>
|
||||
<q-icon name="logout" size="24px" />
|
||||
<span>ออกจากระบบ</span>
|
||||
</button>
|
||||
</div> -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
|
|
|
|||
452
frontend_management/pages/admin/audit-logs.vue
Normal file
452
frontend_management/pages/admin/audit-logs.vue
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
<template>
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Audit Logs</h1>
|
||||
<p class="text-gray-600 mt-1">ประวัติการใช้งานระบบและกิจกรรมต่างๆ</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<q-btn
|
||||
color="primary"
|
||||
outline
|
||||
icon="refresh"
|
||||
label="รีเฟรช"
|
||||
@click="refreshData"
|
||||
/>
|
||||
<q-btn
|
||||
color="negative"
|
||||
flat
|
||||
icon="delete_sweep"
|
||||
label="ล้างประวัติเก่า"
|
||||
@click="openCleanupDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6 mb-8">
|
||||
<q-card class="bg-primary text-white">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Logs ทั้งหมด</div>
|
||||
<div class="text-h3 text-weight-bold">{{ stats.totalLogs }}</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="bg-secondary text-white">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Logs วันนี้</div>
|
||||
<div class="text-h3 text-weight-bold">{{ stats.todayLogs }}</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<q-card class="mb-6">
|
||||
<q-card-section>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<q-input
|
||||
v-model.number="filters.userId"
|
||||
label="User ID"
|
||||
outlined
|
||||
dense
|
||||
type="number"
|
||||
clearable
|
||||
/>
|
||||
<q-select
|
||||
v-model="filters.action"
|
||||
label="Action"
|
||||
outlined
|
||||
dense
|
||||
:options="actionOptions"
|
||||
use-input
|
||||
fill-input
|
||||
hide-selected
|
||||
input-debounce="0"
|
||||
@filter="filterActions"
|
||||
clearable
|
||||
/>
|
||||
<q-input
|
||||
v-model="filters.entityType"
|
||||
label="Entity Type"
|
||||
outlined
|
||||
dense
|
||||
clearable
|
||||
/>
|
||||
<q-input
|
||||
v-model="filters.startDate"
|
||||
label="Start Date"
|
||||
outlined
|
||||
dense
|
||||
type="date"
|
||||
stack-label
|
||||
clearable
|
||||
/>
|
||||
<q-input
|
||||
v-model="filters.endDate"
|
||||
label="End Date"
|
||||
outlined
|
||||
dense
|
||||
type="date"
|
||||
stack-label
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<q-btn flat label="ล้างตัวกรอง" color="grey" @click="resetFilters" />
|
||||
<q-btn color="primary" icon="search" label="ค้นหา" @click="fetchLogs" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Data Table -->
|
||||
<q-table
|
||||
:rows="logs"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
v-model:pagination="pagination"
|
||||
@request="onRequest"
|
||||
:rows-per-page-options="[5, 10, 20, 50, 0]"
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<!-- Action Custom Column -->
|
||||
<template v-slot:body-cell-action="props">
|
||||
<q-td :props="props">
|
||||
<q-badge :color="getActionColor(props.value)">
|
||||
{{ props.value }}
|
||||
</q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- User Custom Column -->
|
||||
<template v-slot:body-cell-user="props">
|
||||
<q-td :props="props">
|
||||
<div v-if="props.row.user">
|
||||
<div class="font-bold">{{ props.row.user.username }}</div>
|
||||
<div class="text-xs text-gray-500">{{ props.row.user.email }} (ID: {{ props.row.user.id }})</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Created At Custom Column -->
|
||||
<template v-slot:body-cell-created_at="props">
|
||||
<q-td :props="props">
|
||||
{{ formatDate(props.value) }}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" align="center">
|
||||
<q-btn flat round color="primary" icon="visibility" @click="viewDetails(props.row)">
|
||||
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<!-- Details Dialog -->
|
||||
<q-dialog v-model="detailsDialog" full-width>
|
||||
<q-card v-if="selectedLog">
|
||||
<q-card-section>
|
||||
<div class="text-h6">รายละเอียด Audit Log #{{ selectedLog.id }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-subtitle2 text-grey">User</div>
|
||||
<div>{{ selectedLog.user?.username }} ({{ selectedLog.user?.email }})</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-subtitle2 text-grey">Action</div>
|
||||
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-subtitle2 text-grey">Time</div>
|
||||
<div>{{ formatDate(selectedLog.created_at) }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-subtitle2 text-grey">Entity</div>
|
||||
<div>{{ selectedLog.entity_type }} #{{ selectedLog.entity_id }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<q-separator class="my-4" />
|
||||
|
||||
<!-- Changes -->
|
||||
<div v-if="selectedLog.old_value || selectedLog.new_value" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-subtitle2 text-grey mb-1">Old Value</div>
|
||||
<pre class="bg-red-50 p-2 rounded text-xs overflow-auto max-h-40 border border-red-100">{{ tryFormatJson(selectedLog.old_value) }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-subtitle2 text-grey mb-1">New Value</div>
|
||||
<pre class="bg-green-50 p-2 rounded text-xs overflow-auto max-h-40 border border-green-100">{{ tryFormatJson(selectedLog.new_value) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div v-if="selectedLog.metadata" class="mt-4">
|
||||
<div class="text-subtitle2 text-grey mb-1">Metadata</div>
|
||||
<pre class="bg-gray-50 p-2 rounded text-xs overflow-auto max-h-40 border border-gray-200">{{ tryFormatJson(selectedLog.metadata) }}</pre>
|
||||
</div>
|
||||
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="bg-white text-primary">
|
||||
<q-btn flat label="ปิด" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Cleanup Dialog -->
|
||||
<q-dialog v-model="cleanupDialog">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">ล้างประวัติเก่า</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<p>เลือกระยะเวลาที่ต้องการเก็บไว้ (ลบข้อมูลที่เก่ากว่ากำหนด):</p>
|
||||
<q-select
|
||||
v-model="cleanupDays"
|
||||
:options="[30, 60, 90, 180, 365]"
|
||||
label="จำนวนวัน"
|
||||
suffix="วัน"
|
||||
outlined
|
||||
dense
|
||||
/>
|
||||
<div class="bg-red-50 text-red-800 p-3 rounded mt-4 text-sm">
|
||||
<q-icon name="warning" class="mr-1"/>
|
||||
การกระทำนี้ไม่สามารถเรียกคืนข้อมูลได้
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="ยกเลิก" v-close-popup />
|
||||
<q-btn flat label="ลบข้อมูล" color="negative" @click="confirmCleanup" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'auth'
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
|
||||
// State
|
||||
const logs = ref<AuditLog[]>([]);
|
||||
const stats = ref<AuditLogStats>({
|
||||
totalLogs: 0,
|
||||
todayLogs: 0,
|
||||
actionSummary: [],
|
||||
recentActivity: []
|
||||
});
|
||||
const loading = ref(false);
|
||||
const detailsDialog = ref(false);
|
||||
const selectedLog = ref<AuditLog | null>(null);
|
||||
const cleanupDialog = ref(false);
|
||||
const cleanupDays = ref(90);
|
||||
|
||||
// Filters
|
||||
const filters = reactive({
|
||||
userId: undefined as number | undefined,
|
||||
action: null as string | null,
|
||||
entityType: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
// Pagination for q-table
|
||||
const pagination = ref({
|
||||
sortBy: 'created_at',
|
||||
descending: true,
|
||||
page: 1,
|
||||
rowsPerPage: 20,
|
||||
rowsNumber: 0
|
||||
});
|
||||
|
||||
// Table setup
|
||||
const columns = [
|
||||
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
|
||||
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
|
||||
{ name: 'user', label: 'User', field: 'user', align: 'left' },
|
||||
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
|
||||
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
|
||||
|
||||
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
||||
];
|
||||
|
||||
// Actions options (for filtering)
|
||||
const actionOptionsList = [
|
||||
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT',
|
||||
'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ',
|
||||
'APPROVE_COURSE', 'REJECT_COURSE',
|
||||
'UPLOAD_FILE', 'DELETE_FILE',
|
||||
'CHANGE_PASSWORD', 'RESET_PASSWORD',
|
||||
'VERIFY_EMAIL', 'DEACTIVATE_USER', 'ACTIVATE_USER'
|
||||
];
|
||||
const actionOptions = ref(actionOptionsList);
|
||||
|
||||
const filterActions = (val: string, update: Function) => {
|
||||
if (val === '') {
|
||||
update(() => {
|
||||
actionOptions.value = actionOptionsList;
|
||||
});
|
||||
return;
|
||||
}
|
||||
update(() => {
|
||||
const needle = val.toLowerCase();
|
||||
actionOptions.value = actionOptionsList.filter(v => v.toLowerCase().indexOf(needle) > -1);
|
||||
});
|
||||
};
|
||||
|
||||
// Methods
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { page, rowsPerPage } = pagination.value;
|
||||
|
||||
const response = await adminService.getAuditLogs(page, rowsPerPage, {
|
||||
userId: filters.userId,
|
||||
action: filters.action || undefined,
|
||||
entityType: filters.entityType || undefined,
|
||||
startDate: filters.startDate ? new Date(filters.startDate).toISOString() : undefined,
|
||||
endDate: filters.endDate ? new Date(filters.endDate).toISOString() : undefined
|
||||
});
|
||||
|
||||
logs.value = response.data;
|
||||
pagination.value.rowsNumber = response.pagination.total;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch logs:', error);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูล Logs ได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
stats.value = await adminService.getAuditLogStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
fetchStats();
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
const onRequest = (props: any) => {
|
||||
const { page, rowsPerPage } = props.pagination;
|
||||
pagination.value.page = page;
|
||||
pagination.value.rowsPerPage = rowsPerPage;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.userId = undefined;
|
||||
filters.action = null;
|
||||
filters.entityType = '';
|
||||
filters.startDate = '';
|
||||
filters.endDate = '';
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
const viewDetails = (log: AuditLog) => {
|
||||
selectedLog.value = log;
|
||||
detailsDialog.value = true;
|
||||
};
|
||||
|
||||
const openCleanupDialog = () => {
|
||||
cleanupDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmCleanup = async () => {
|
||||
try {
|
||||
await adminService.cleanupAuditLogs(cleanupDays.value);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: `ลบข้อมูลที่เก่ากว่า ${cleanupDays.value} วันเรียบร้อยแล้ว`,
|
||||
position: 'top'
|
||||
});
|
||||
cleanupDialog.value = false;
|
||||
refreshData();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถลบข้อมูลได้',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Utilities
|
||||
const tryFormatJson = (str: string | null) => {
|
||||
if (!str) return '-';
|
||||
try {
|
||||
const obj = JSON.parse(str);
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString('th-TH');
|
||||
};
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
if (!action) return 'grey';
|
||||
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE')) return 'negative';
|
||||
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
|
||||
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
|
||||
if (action.includes('LOGIN')) return 'info';
|
||||
return 'grey-8';
|
||||
};
|
||||
|
||||
// Check for deep link to detail
|
||||
const route = useRoute();
|
||||
onMounted(() => {
|
||||
fetchStats();
|
||||
fetchLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Hide scrollbar for number input */
|
||||
:deep(input[type=number]::-webkit-outer-spin-button),
|
||||
:deep(input[type=number]::-webkit-inner-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(input[type=number]) {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
row-key="id"
|
||||
:loading="loading"
|
||||
flat
|
||||
:rows-per-page-options="[5, 10, 20, 50, 0]"
|
||||
bordered
|
||||
>
|
||||
<!-- Name -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<q-icon name="school" size="48px" class="text-primary-200" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="card">
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<q-icon name="people" size="48px" class="text-secondary-200" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="card">
|
||||
</div>
|
||||
|
||||
<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 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 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">
|
||||
<h3 class="font-semibold">Python เบื้องต้น</h3>
|
||||
<p class="text-sm text-gray-600">45 ผู้เรียน • 8 บทเรียน</p>
|
||||
<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>
|
||||
<q-btn flat color="primary" label="ดูรายละเอียด" />
|
||||
</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>
|
||||
583
frontend_management/pages/admin/profile/index.vue
Normal file
583
frontend_management/pages/admin/profile/index.vue
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">โปรไฟล์ของฉัน</h1>
|
||||
<p class="text-gray-600 mt-2">จัดการข้อมูลส่วนตัวของคุณ</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Card -->
|
||||
<div class="mb-10 ">
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-32 h-32 rounded-full flex items-center justify-center text-6xl mb-4 overflow-hidden bg-primary-100">
|
||||
<img
|
||||
v-if="profile.avatarUrl"
|
||||
:key="profile.avatarUrl"
|
||||
:src="profile.avatarUrl"
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
@error="onAvatarError"
|
||||
/>
|
||||
<span v-else>{{ profile.avatar }}</span>
|
||||
</div>
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
label="เปลี่ยนรูป"
|
||||
icon="photo_camera"
|
||||
:loading="uploadingAvatar"
|
||||
@click="triggerAvatarUpload"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-2">ขนาดไม่เกิน 5MB</p>
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleAvatarUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="flex-1">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">ชื่อ-นามสกุล</div>
|
||||
<div class="text-lg text-gray-900">{{ profile.fullName }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">อีเมล</div>
|
||||
<div class="text-lg text-gray-900 flex items-center gap-2">
|
||||
{{ profile.email }}
|
||||
<q-badge v-if="profile.emailVerified" color="positive" label="ยืนยันแล้ว" />
|
||||
<q-badge v-else color="warning" label="ยังไม่ยืนยัน" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">Username</div>
|
||||
<div class="text-lg text-gray-900">{{ profile.username }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">เบอร์โทร</div>
|
||||
<div class="text-lg text-gray-900">{{ profile.phone || '-' }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">ตำแหน่ง</div>
|
||||
<div class="text-lg text-gray-900">{{ profile.roleName || getRoleLabel(profile.role) }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">วันที่สมัคร</div>
|
||||
<div class="text-lg text-gray-900">{{ formatDate(profile.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-3 mt-6">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="แก้ไขโปรไฟล์"
|
||||
icon="edit"
|
||||
@click="showEditModal = true"
|
||||
/>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey-7"
|
||||
label="เปลี่ยนรหัสผ่าน"
|
||||
icon="lock"
|
||||
@click="showPasswordModal = true"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="!profile.emailVerified"
|
||||
outline
|
||||
color="orange"
|
||||
label="ขอยืนยันอีเมล"
|
||||
icon="mark_email_unread"
|
||||
:loading="sendingVerification"
|
||||
@click="handleSendVerificationEmail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5">
|
||||
<AppCard>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-primary-600 mb-2">{{ stats.totalCourses }}</div>
|
||||
<div class="text-gray-600">หลักสูตรที่สร้าง</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-secondary-600 mb-2">{{ stats.totalStudents }}</div>
|
||||
<div class="text-gray-600">ผู้เรียนทั้งหมด</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div> -->
|
||||
|
||||
<!-- Edit Profile Modal -->
|
||||
<q-dialog v-model="showEditModal" persistent>
|
||||
<q-card style="min-width: 500px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">แก้ไขโปรไฟล์</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense @click="showEditModal = false" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit="handleUpdateProfile">
|
||||
<q-input
|
||||
v-model="editForm.firstName"
|
||||
label="ชื่อ"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="editForm.lastName"
|
||||
label="นามสกุล"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกนามสกุล']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="editForm.phone"
|
||||
label="เบอร์โทร"
|
||||
outlined
|
||||
class="mb-4"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="phone" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<q-btn
|
||||
flat
|
||||
label="ยกเลิก"
|
||||
color="grey-7"
|
||||
@click="showEditModal = false"
|
||||
/>
|
||||
<q-btn
|
||||
type="submit"
|
||||
label="บันทึก"
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<q-dialog v-model="showPasswordModal" persistent>
|
||||
<q-card style="min-width: 500px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">เปลี่ยนรหัสผ่าน</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense @click="showPasswordModal = false" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit="handleChangePassword">
|
||||
<q-input
|
||||
v-model="passwordForm.currentPassword"
|
||||
label="รหัสผ่านปัจจุบัน"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกรหัสผ่านปัจจุบัน']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showCurrentPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="passwordForm.newPassword"
|
||||
label="รหัสผ่านใหม่"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[
|
||||
val => !!val || 'กรุณากรอกรหัสผ่านใหม่',
|
||||
val => val.length >= 6 || 'รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร'
|
||||
]"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showNewPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
label="ยืนยันรหัสผ่านใหม่"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[
|
||||
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
||||
val => val === passwordForm.newPassword || 'รหัสผ่านไม่ตรงกัน'
|
||||
]"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showConfirmPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<q-btn
|
||||
flat
|
||||
label="ยกเลิก"
|
||||
color="grey-7"
|
||||
@click="showPasswordModal = false"
|
||||
/>
|
||||
<q-btn
|
||||
type="submit"
|
||||
label="เปลี่ยนรหัสผ่าน"
|
||||
color="primary"
|
||||
:loading="changingPassword"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { userService, type UserProfileResponse } from '~/services/user.service';
|
||||
import { authService } from '~/services/auth.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'auth'
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Loading state
|
||||
const loading = ref(true);
|
||||
|
||||
// Profile data
|
||||
const profile = ref({
|
||||
fullName: '',
|
||||
email: '',
|
||||
emailVerified: false,
|
||||
username: '',
|
||||
phone: '',
|
||||
role: '',
|
||||
roleName: '',
|
||||
avatar: '',
|
||||
avatarUrl: '' as string | null,
|
||||
createdAt: ''
|
||||
});
|
||||
|
||||
const stats = ref({
|
||||
totalCourses: 5,
|
||||
totalStudents: 125
|
||||
});
|
||||
|
||||
// Edit form
|
||||
const showEditModal = ref(false);
|
||||
const saving = ref(false);
|
||||
const editForm = ref({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
// Password form
|
||||
const showPasswordModal = ref(false);
|
||||
const changingPassword = ref(false);
|
||||
const showCurrentPassword = ref(false);
|
||||
const showNewPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const passwordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
// Email verification
|
||||
const sendingVerification = ref(false);
|
||||
|
||||
// Methods
|
||||
const getRoleLabel = (role: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
INSTRUCTOR: 'ผู้สอน',
|
||||
ADMIN: 'ผู้ดูแลระบบ',
|
||||
STUDENT: 'ผู้เรียน'
|
||||
};
|
||||
return labels[role] || role;
|
||||
};
|
||||
|
||||
const formatDate = (date: string, includeTime = true) => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
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
|
||||
const avatarInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadingAvatar = ref(false);
|
||||
|
||||
const triggerAvatarUpload = () => {
|
||||
avatarInputRef.value?.click();
|
||||
};
|
||||
|
||||
const onAvatarError = () => {
|
||||
// Fallback to emoji if image fails
|
||||
profile.value.avatarUrl = null;
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'กรุณาเลือกไฟล์รูปภาพเท่านั้น',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'ไฟล์มีขนาดใหญ่เกิน 5MB',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingAvatar.value = true;
|
||||
try {
|
||||
const response = await userService.uploadAvatar(file);
|
||||
|
||||
// Re-fetch profile to get presigned URL from backend
|
||||
await fetchProfile();
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message || 'อัพโหลดรูปโปรไฟล์สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to upload avatar:', error);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'เกิดข้อผิดพลาดในการอัพโหลดรูป',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
uploadingAvatar.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
// Call real API to update profile
|
||||
const response = await userService.updateProfile({
|
||||
first_name: editForm.value.firstName,
|
||||
last_name: editForm.value.lastName,
|
||||
phone: editForm.value.phone || null
|
||||
});
|
||||
|
||||
// Refresh profile data from API
|
||||
await fetchProfile();
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message || 'อัพเดทโปรไฟล์สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
showEditModal.value = false;
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
changingPassword.value = true;
|
||||
|
||||
try {
|
||||
// Call real API to change password
|
||||
const response = await userService.changePassword(
|
||||
passwordForm.value.currentPassword,
|
||||
passwordForm.value.newPassword
|
||||
);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message || 'เปลี่ยนรหัสผ่านสำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
showPasswordModal.value = false;
|
||||
passwordForm.value = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
};
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || (error.response?.status === 401
|
||||
? 'รหัสผ่านปัจจุบันไม่ถูกต้อง'
|
||||
: 'เกิดข้อผิดพลาดในการเปลี่ยนรหัสผ่าน'),
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendVerificationEmail = async () => {
|
||||
sendingVerification.value = true;
|
||||
try {
|
||||
const response = await authService.sendVerifyEmail();
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message || 'ส่งอีเมลยืนยันไปยังอีเมลของคุณแล้ว',
|
||||
position: 'top'
|
||||
});
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'เกิดข้อผิดพลาดในการส่งอีเมลยืนยัน',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
sendingVerification.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch edit modal
|
||||
watch(showEditModal, (newVal) => {
|
||||
if (newVal) {
|
||||
// Split fullName into firstName and lastName for editing
|
||||
const nameParts = profile.value.fullName.split(' ');
|
||||
editForm.value = {
|
||||
firstName: nameParts[0] || '',
|
||||
lastName: nameParts.slice(1).join(' ') || '',
|
||||
phone: profile.value.phone
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch profile from API
|
||||
const fetchProfile = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await userService.getProfile();
|
||||
|
||||
// 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) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูลโปรไฟล์ได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load profile on mount
|
||||
onMounted(() => {
|
||||
fetchProfile();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -4,13 +4,13 @@
|
|||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-primary-600">จัดการผู้ใช้งาน</h1>
|
||||
<div class="flex gap-3">
|
||||
<q-btn
|
||||
<!-- <q-btn
|
||||
outline
|
||||
color="red"
|
||||
label="ส่งออก Excel"
|
||||
icon="download"
|
||||
@click="exportExcel"
|
||||
/>
|
||||
/> -->
|
||||
<!-- <q-btn
|
||||
color="primary"
|
||||
label="+ เพิ่มผู้ใช้ใหม่"
|
||||
|
|
@ -76,6 +76,7 @@
|
|||
:loading="loading"
|
||||
flat
|
||||
:pagination="pagination"
|
||||
:rows-per-page-options="[5, 10, 20, 50, 0]"
|
||||
@update:pagination="pagination = $event"
|
||||
>
|
||||
<!-- User Info -->
|
||||
|
|
|
|||
|
|
@ -1,47 +1,18 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold text-primary-600 mb-4">
|
||||
E-Learning Management System
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 mb-8">
|
||||
ระบบจัดการเรียนการสอนออนไลน์
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<q-card class="shadow-lg">
|
||||
<q-card-section>
|
||||
<div class="text-h6 text-primary-600">📚 Courses</div>
|
||||
<p class="text-gray-600">จัดการหลักสูตรและเนื้อหา</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="shadow-lg">
|
||||
<q-card-section>
|
||||
<div class="text-h6 text-secondary-500">👥 Students</div>
|
||||
<p class="text-gray-600">จัดการผู้เรียน</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="shadow-lg">
|
||||
<q-card-section>
|
||||
<div class="text-h6 text-accent-500">📊 Reports</div>
|
||||
<p class="text-gray-600">รายงานและสถิติ</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
label="login"
|
||||
@click="handleLogin"
|
||||
/>
|
||||
<div class="flex items-center justify-center min-h-screen bg-gray-50">
|
||||
<div class="text-center">
|
||||
<q-spinner color="primary" size="3em" />
|
||||
<p class="mt-4 text-gray-500">Redirecting to login...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Home page
|
||||
const handleLogin = async () => {
|
||||
await navigateTo('/login')
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(async () => {
|
||||
await router.push('/login');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="w-full md:w-48 h-32 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 relative group cursor-pointer overflow-hidden border border-gray-200"
|
||||
class="bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 relative group cursor-pointer overflow-hidden border border-gray-200"
|
||||
style="width: 192px; height: 128px;"
|
||||
@click="triggerThumbnailUpload"
|
||||
>
|
||||
<img
|
||||
|
|
@ -73,7 +74,7 @@
|
|||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<q-icon name="people" size="20px" />
|
||||
<span>0 ผู้เรียน</span>
|
||||
<span>{{ totalStudentsCount }} ผู้เรียน</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -166,6 +167,7 @@ const course = ref<CourseDetailResponse | null>(null);
|
|||
const activeTab = ref('structure');
|
||||
const uploadingThumbnail = ref(false);
|
||||
const thumbnailInputRef = ref<HTMLInputElement | null>(null);
|
||||
const totalStudentsCount = ref(0);
|
||||
|
||||
// Computed
|
||||
const totalLessons = computed(() => {
|
||||
|
|
@ -178,7 +180,13 @@ const fetchCourse = async () => {
|
|||
loading.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
course.value = await instructorService.getCourseById(courseId);
|
||||
const [courseData, studentsData] = await Promise.all([
|
||||
instructorService.getCourseById(courseId),
|
||||
instructorService.getEnrolledStudents(courseId, 1, 1)
|
||||
]);
|
||||
|
||||
course.value = courseData;
|
||||
totalStudentsCount.value = studentsData.total;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch course:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้', position: 'top' });
|
||||
|
|
@ -187,6 +195,7 @@ const fetchCourse = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
APPROVED: 'green',
|
||||
|
|
@ -238,8 +247,8 @@ const handleThumbnailUpload = async (event: Event) => {
|
|||
const requestApproval = async () => {
|
||||
if (!course.value) return;
|
||||
try {
|
||||
await instructorService.submitCourseForApproval(course.value.id);
|
||||
$q.notify({ type: 'positive', message: 'ส่งขออนุมัติสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.submitCourseForApproval(course.value.id);
|
||||
$q.notify({ type: 'positive', message: response.message || 'ส่งขออนุมัติสำเร็จ', position: 'top' });
|
||||
fetchCourse();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
|
|
|
|||
|
|
@ -86,12 +86,39 @@
|
|||
|
||||
<!-- Chart and Recent Courses -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Chart Placeholder -->
|
||||
<!-- Course Status Breakdown -->
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="text-xl font-semibold mb-4"> สถิติผู้สมัครรวม (รายเดือน)</h3>
|
||||
<div class="bg-gray-100 rounded-lg p-12 text-center text-gray-500">
|
||||
[กราฟแสดงสถิติผู้สมัครรวม (รายเดือน)]
|
||||
<h3 class="text-xl font-semibold mb-4">สถานะหลักสูตร</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<q-icon name="check_circle" color="green" size="24px" />
|
||||
<span class="font-medium text-gray-700">เผยแพร่แล้ว</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold text-green-600">{{ instructorStore.courseStatusCounts.approved }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<q-icon name="hourglass_empty" color="orange" size="24px" />
|
||||
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold text-orange-600">{{ instructorStore.courseStatusCounts.pending }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<q-icon name="edit_note" color="grey" size="24px" />
|
||||
<span class="font-medium text-gray-700">แบบร่าง</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold text-gray-600">{{ instructorStore.courseStatusCounts.draft }}</span>
|
||||
</div>
|
||||
<div v-if="instructorStore.courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<q-icon name="cancel" color="red" size="24px" />
|
||||
<span class="font-medium text-gray-700">ถูกปฏิเสธ</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold text-red-600">{{ instructorStore.courseStatusCounts.rejected }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -170,5 +197,6 @@ const handleLogout = () => {
|
|||
// Fetch dashboard data on mount
|
||||
onMounted(() => {
|
||||
instructorStore.fetchDashboardData();
|
||||
authStore.fetchUserProfile();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -239,9 +239,10 @@ const handleRegister = async () => {
|
|||
|
||||
router.push('/login');
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่';
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
message: errorMessage,
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -214,6 +214,46 @@ export interface UpdateCategoryRequest {
|
|||
};
|
||||
}
|
||||
|
||||
// Audit Logs Interfaces
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: number;
|
||||
old_value: string | null;
|
||||
new_value: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
metadata: string | null;
|
||||
created_at: string;
|
||||
user: {
|
||||
email: string;
|
||||
username: string;
|
||||
id: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AuditLogsListResponse {
|
||||
data: AuditLog[];
|
||||
pagination: {
|
||||
totalPages: number;
|
||||
total: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditLogStats {
|
||||
totalLogs: number;
|
||||
todayLogs: number;
|
||||
actionSummary: {
|
||||
action: string;
|
||||
count: number;
|
||||
}[];
|
||||
recentActivity: AuditLog[];
|
||||
}
|
||||
|
||||
// Helper function to get auth token from cookie
|
||||
const getAuthToken = (): string => {
|
||||
const tokenCookie = useCookie('token');
|
||||
|
|
@ -391,6 +431,92 @@ export const adminService = {
|
|||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
// ============ Audit Logs ============
|
||||
async getAuditLogs(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
filters: {
|
||||
userId?: number;
|
||||
action?: string;
|
||||
entityType?: string;
|
||||
entityId?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
} = {}
|
||||
): Promise<AuditLogsListResponse> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getAuthToken();
|
||||
|
||||
let query: any = { page, limit };
|
||||
if (filters.userId) query.userId = filters.userId;
|
||||
if (filters.action) query.action = filters.action;
|
||||
if (filters.entityType) query.entityType = filters.entityType;
|
||||
if (filters.entityId) query.entityId = filters.entityId;
|
||||
if (filters.startDate) query.startDate = filters.startDate;
|
||||
if (filters.endDate) query.endDate = filters.endDate;
|
||||
|
||||
const response = await $fetch<AuditLogsListResponse>('/api/admin/audit-logs', {
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
query
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
async getAuditLogById(id: number): Promise<AuditLog> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getAuthToken();
|
||||
const response = await $fetch<AuditLog>(`/api/admin/audit-logs/${id}`, {
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
async getAuditLogStats(): Promise<AuditLogStats> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getAuthToken();
|
||||
const response = await $fetch<AuditLogStats>('/api/admin/audit-logs/stats/summary', {
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
async getAuditLogsByEntity(entityType: string, entityId: number): Promise<AuditLog[]> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getAuthToken();
|
||||
const response = await $fetch<AuditLog[]>(`/api/admin/audit-logs/entity/${entityType}/${entityId}`, {
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
async getAuditLogsByUser(userId: number, limit: number = 20): Promise<AuditLog[]> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getAuthToken();
|
||||
const response = await $fetch<AuditLog[]>(`/api/admin/audit-logs/user/${userId}/activity`, {
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
query: { limit }
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
async cleanupAuditLogs(days: number = 90): Promise<ApiResponse<void>> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getAuthToken();
|
||||
const response = await $fetch<ApiResponse<void>>('/api/admin/audit-logs/cleanup', {
|
||||
method: 'DELETE',
|
||||
baseURL: config.public.apiBaseUrl as string,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
query: { days }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ export const instructorService = {
|
|||
|
||||
async submitCourseForApproval(courseId: number): Promise<ApiResponse<void>> {
|
||||
return await authRequest<ApiResponse<void>>(
|
||||
`/api/instructors/courses/${courseId}/submit`,
|
||||
`/api/instructors/courses/send-review/${courseId}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { authService } from '~/services/auth.service';
|
||||
import { userService } from '~/services/user.service';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
|
|
@ -123,6 +124,28 @@ export const useAuthStore = defineStore('auth', {
|
|||
this.logout();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUserProfile() {
|
||||
try {
|
||||
const response = await userService.getProfile();
|
||||
|
||||
// Update local user state
|
||||
this.user = {
|
||||
id: response.id.toString(),
|
||||
email: response.email,
|
||||
firstName: response.profile.first_name,
|
||||
lastName: response.profile.last_name,
|
||||
role: response.role.code as 'INSTRUCTOR' | 'ADMIN' | 'STUDENT',
|
||||
avatarUrl: response.profile.avatar_url
|
||||
};
|
||||
|
||||
// Update user cookie to keep it in sync
|
||||
const userCookie = useCookie('user');
|
||||
userCookie.value = JSON.stringify(this.user);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user profile:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ interface DashboardStats {
|
|||
completedStudents: number;
|
||||
}
|
||||
|
||||
interface CourseStatusCounts {
|
||||
approved: number;
|
||||
pending: number;
|
||||
draft: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export const useInstructorStore = defineStore('instructor', {
|
||||
state: () => ({
|
||||
stats: {
|
||||
|
|
@ -24,6 +31,13 @@ export const useInstructorStore = defineStore('instructor', {
|
|||
completedStudents: 0
|
||||
} as DashboardStats,
|
||||
|
||||
courseStatusCounts: {
|
||||
approved: 0,
|
||||
pending: 0,
|
||||
draft: 0,
|
||||
rejected: 0
|
||||
} as CourseStatusCounts,
|
||||
|
||||
recentCourses: [] as Course[],
|
||||
loading: false
|
||||
}),
|
||||
|
|
@ -40,21 +54,64 @@ export const useInstructorStore = defineStore('instructor', {
|
|||
// Fetch real courses from API
|
||||
const courses = await instructorService.getCourses();
|
||||
|
||||
// Update stats
|
||||
this.stats.totalCourses = courses.length;
|
||||
// TODO: Get real student counts from API when available
|
||||
this.stats.totalStudents = 0;
|
||||
this.stats.completedStudents = 0;
|
||||
// Fetch student counts for each course
|
||||
let totalStudents = 0;
|
||||
let completedStudents = 0;
|
||||
const courseDetails: Course[] = [];
|
||||
|
||||
// Map to recent courses format (take first 5)
|
||||
this.recentCourses = courses.slice(0, 3).map((course, index) => ({
|
||||
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: 0, // TODO: Get from API
|
||||
lessons: 0, // TODO: Get from course detail API
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue