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 }}
|
Chapter {{ chapter.sort_order }}: {{ chapter.title.th }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
{{ chapter.lessons.length }} บทเรียน · {{ getChapterDuration(chapter) }} นาที
|
{{ chapter.lessons.length }} บทเรียน
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,9 +53,6 @@
|
||||||
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
|
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section side>
|
|
||||||
<span class="text-sm text-gray-500">{{ lesson.duration_minutes }} นาที</span>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
|
||||||
|
|
@ -54,24 +54,24 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<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"
|
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"
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
>
|
>
|
||||||
<q-icon name="settings" size="24px" />
|
<q-icon name="settings" size="24px" />
|
||||||
<span>ตั้งค่าระบบ</span>
|
<span>Audit logs</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</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
|
<button
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
|
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>
|
<span>ออกจากระบบ</span>
|
||||||
</button>
|
</button>
|
||||||
</div> -->
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,15 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</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
|
<button
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
|
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>
|
<span>ออกจากระบบ</span>
|
||||||
</button>
|
</button>
|
||||||
</div> -->
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- 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"
|
row-key="id"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
flat
|
flat
|
||||||
|
:rows-per-page-options="[5, 10, 20, 50, 0]"
|
||||||
bordered
|
bordered
|
||||||
>
|
>
|
||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,15 @@
|
||||||
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'ADMIN' }}</div>
|
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'ADMIN' }}</div>
|
||||||
</div>
|
</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-menu>
|
||||||
<q-list style="min-width: 200px">
|
<q-list style="min-width: 200px">
|
||||||
<!-- User Info Header -->
|
<!-- User Info Header -->
|
||||||
|
|
@ -56,52 +62,127 @@
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<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">
|
||||||
<div class="card">
|
<div class="card bg-white p-6 rounded-lg shadow-sm">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 text-sm">หลักสูตรทั้งหมด</p>
|
<p class="text-gray-600 text-sm">คอร์สรออนุมัติ</p>
|
||||||
<p class="text-3xl font-bold text-primary-600">5</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>
|
</div>
|
||||||
<q-icon name="school" size="48px" class="text-primary-200" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
|
<div class="card bg-white p-6 rounded-lg shadow-sm">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 text-sm">ผู้เรียนทั้งหมด</p>
|
<p class="text-gray-600 text-sm">กิจกรรมวันนี้</p>
|
||||||
<p class="text-3xl font-bold text-secondary-500">125</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>
|
</div>
|
||||||
<q-icon name="people" size="48px" class="text-secondary-200" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
|
<div class="card bg-white p-6 rounded-lg shadow-sm">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 text-sm">เรียนจบแล้ว</p>
|
<p class="text-gray-600 text-sm">ผู้ใช้งานทั้งหมด</p>
|
||||||
<p class="text-3xl font-bold text-accent-500">45</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Recent Courses -->
|
|
||||||
<div class="card">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Dashboard</h2>
|
<!-- Pending Courses List -->
|
||||||
<!-- <div class="space-y-4">
|
<div class="card bg-white rounded-lg shadow-sm">
|
||||||
<div class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
<div class="p-6 border-b flex justify-between items-center">
|
||||||
<div class="w-16 h-16 bg-primary-100 rounded-lg flex items-center justify-center">
|
<h2 class="text-lg font-semibold text-gray-900">คอร์สรอตรวจสอบล่าสุด</h2>
|
||||||
<q-icon name="code" size="32px" class="text-primary-600" />
|
<q-btn flat color="primary" label="ดูทั้งหมด" to="/admin/courses/pending" size="sm" />
|
||||||
</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>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { adminService, type PendingCourse, type AuditLog } from '~/services/admin.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'admin',
|
layout: 'admin',
|
||||||
middleware: 'auth'
|
middleware: 'auth'
|
||||||
|
|
@ -110,6 +191,16 @@ definePageMeta({
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
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
|
// Navigation functions
|
||||||
const goToProfile = () => {
|
const goToProfile = () => {
|
||||||
router.push('/admin/profile');
|
router.push('/admin/profile');
|
||||||
|
|
@ -119,4 +210,67 @@ const handleLogout = () => {
|
||||||
authStore.logout();
|
authStore.logout();
|
||||||
router.push('/login');
|
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>
|
</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">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-2xl font-bold text-primary-600">จัดการผู้ใช้งาน</h1>
|
<h1 class="text-2xl font-bold text-primary-600">จัดการผู้ใช้งาน</h1>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<q-btn
|
<!-- <q-btn
|
||||||
outline
|
outline
|
||||||
color="red"
|
color="red"
|
||||||
label="ส่งออก Excel"
|
label="ส่งออก Excel"
|
||||||
icon="download"
|
icon="download"
|
||||||
@click="exportExcel"
|
@click="exportExcel"
|
||||||
/>
|
/> -->
|
||||||
<!-- <q-btn
|
<!-- <q-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
label="+ เพิ่มผู้ใช้ใหม่"
|
label="+ เพิ่มผู้ใช้ใหม่"
|
||||||
|
|
@ -76,6 +76,7 @@
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
flat
|
flat
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
|
:rows-per-page-options="[5, 10, 20, 50, 0]"
|
||||||
@update:pagination="pagination = $event"
|
@update:pagination="pagination = $event"
|
||||||
>
|
>
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
<h1 class="text-4xl font-bold text-primary-600 mb-4">
|
<div class="text-center">
|
||||||
E-Learning Management System
|
<q-spinner color="primary" size="3em" />
|
||||||
</h1>
|
<p class="mt-4 text-gray-500">Redirecting to login...</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Home page
|
// Home page
|
||||||
const handleLogin = async () => {
|
const router = useRouter();
|
||||||
await navigateTo('/login')
|
|
||||||
}
|
onMounted(async () => {
|
||||||
|
await router.push('/login');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
<div class="flex flex-col md:flex-row gap-6">
|
<div class="flex flex-col md:flex-row gap-6">
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div
|
<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"
|
@click="triggerThumbnailUpload"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|
@ -73,7 +74,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<q-icon name="people" size="20px" />
|
<q-icon name="people" size="20px" />
|
||||||
<span>0 ผู้เรียน</span>
|
<span>{{ totalStudentsCount }} ผู้เรียน</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -166,6 +167,7 @@ const course = ref<CourseDetailResponse | null>(null);
|
||||||
const activeTab = ref('structure');
|
const activeTab = ref('structure');
|
||||||
const uploadingThumbnail = ref(false);
|
const uploadingThumbnail = ref(false);
|
||||||
const thumbnailInputRef = ref<HTMLInputElement | null>(null);
|
const thumbnailInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const totalStudentsCount = ref(0);
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const totalLessons = computed(() => {
|
const totalLessons = computed(() => {
|
||||||
|
|
@ -178,7 +180,13 @@ const fetchCourse = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const courseId = parseInt(route.params.id as string);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch course:', error);
|
console.error('Failed to fetch course:', error);
|
||||||
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้', position: 'top' });
|
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้', position: 'top' });
|
||||||
|
|
@ -187,6 +195,7 @@ const fetchCourse = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
APPROVED: 'green',
|
APPROVED: 'green',
|
||||||
|
|
@ -238,8 +247,8 @@ const handleThumbnailUpload = async (event: Event) => {
|
||||||
const requestApproval = async () => {
|
const requestApproval = async () => {
|
||||||
if (!course.value) return;
|
if (!course.value) return;
|
||||||
try {
|
try {
|
||||||
await instructorService.submitCourseForApproval(course.value.id);
|
const response = await instructorService.submitCourseForApproval(course.value.id);
|
||||||
$q.notify({ type: 'positive', message: 'ส่งขออนุมัติสำเร็จ', position: 'top' });
|
$q.notify({ type: 'positive', message: response.message || 'ส่งขออนุมัติสำเร็จ', position: 'top' });
|
||||||
fetchCourse();
|
fetchCourse();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,39 @@
|
||||||
|
|
||||||
<!-- Chart and Recent Courses -->
|
<!-- Chart and Recent Courses -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<!-- Chart Placeholder -->
|
<!-- Course Status Breakdown -->
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h3 class="text-xl font-semibold mb-4"> สถิติผู้สมัครรวม (รายเดือน)</h3>
|
<h3 class="text-xl font-semibold mb-4">สถานะหลักสูตร</h3>
|
||||||
<div class="bg-gray-100 rounded-lg p-12 text-center text-gray-500">
|
<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>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -170,5 +197,6 @@ const handleLogout = () => {
|
||||||
// Fetch dashboard data on mount
|
// Fetch dashboard data on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
instructorStore.fetchDashboardData();
|
instructorStore.fetchDashboardData();
|
||||||
|
authStore.fetchUserProfile();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -239,9 +239,10 @@ const handleRegister = async () => {
|
||||||
|
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่';
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.data?.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
message: errorMessage,
|
||||||
position: 'top'
|
position: 'top'
|
||||||
});
|
});
|
||||||
} finally {
|
} 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
|
// Helper function to get auth token from cookie
|
||||||
const getAuthToken = (): string => {
|
const getAuthToken = (): string => {
|
||||||
const tokenCookie = useCookie('token');
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ export const instructorService = {
|
||||||
|
|
||||||
async submitCourseForApproval(courseId: number): Promise<ApiResponse<void>> {
|
async submitCourseForApproval(courseId: number): Promise<ApiResponse<void>> {
|
||||||
return await authRequest<ApiResponse<void>>(
|
return await authRequest<ApiResponse<void>>(
|
||||||
`/api/instructors/courses/${courseId}/submit`,
|
`/api/instructors/courses/send-review/${courseId}`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +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';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -123,6 +124,28 @@ export const useAuthStore = defineStore('auth', {
|
||||||
this.logout();
|
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;
|
completedStudents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CourseStatusCounts {
|
||||||
|
approved: number;
|
||||||
|
pending: number;
|
||||||
|
draft: number;
|
||||||
|
rejected: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const useInstructorStore = defineStore('instructor', {
|
export const useInstructorStore = defineStore('instructor', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
stats: {
|
stats: {
|
||||||
|
|
@ -24,6 +31,13 @@ export const useInstructorStore = defineStore('instructor', {
|
||||||
completedStudents: 0
|
completedStudents: 0
|
||||||
} as DashboardStats,
|
} as DashboardStats,
|
||||||
|
|
||||||
|
courseStatusCounts: {
|
||||||
|
approved: 0,
|
||||||
|
pending: 0,
|
||||||
|
draft: 0,
|
||||||
|
rejected: 0
|
||||||
|
} as CourseStatusCounts,
|
||||||
|
|
||||||
recentCourses: [] as Course[],
|
recentCourses: [] as Course[],
|
||||||
loading: false
|
loading: false
|
||||||
}),
|
}),
|
||||||
|
|
@ -40,21 +54,64 @@ export const useInstructorStore = defineStore('instructor', {
|
||||||
// Fetch real courses from API
|
// Fetch real courses from API
|
||||||
const courses = await instructorService.getCourses();
|
const courses = await instructorService.getCourses();
|
||||||
|
|
||||||
|
// Fetch student counts for each course
|
||||||
|
let totalStudents = 0;
|
||||||
|
let completedStudents = 0;
|
||||||
|
const courseDetails: Course[] = [];
|
||||||
|
|
||||||
|
for (const course of courses.slice(0, 5)) {
|
||||||
|
try {
|
||||||
|
// Get student counts
|
||||||
|
const studentsResponse = await instructorService.getEnrolledStudents(course.id, 1, 1);
|
||||||
|
const courseStudents = studentsResponse.total || 0;
|
||||||
|
totalStudents += courseStudents;
|
||||||
|
|
||||||
|
// Get completed count from full list (if small) or estimate
|
||||||
|
if (courseStudents > 0 && courseStudents <= 100) {
|
||||||
|
const allStudents = await instructorService.getEnrolledStudents(course.id, 1, 100);
|
||||||
|
completedStudents += allStudents.data.filter(s => s.status === 'COMPLETED').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lesson count from course detail
|
||||||
|
const courseDetail = await instructorService.getCourseById(course.id);
|
||||||
|
const lessonCount = courseDetail.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
||||||
|
|
||||||
|
courseDetails.push({
|
||||||
|
id: course.id,
|
||||||
|
title: course.title.th,
|
||||||
|
students: courseStudents,
|
||||||
|
lessons: lessonCount,
|
||||||
|
icon: 'book',
|
||||||
|
thumbnail: course.thumbnail_url || null
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Course might not have students endpoint
|
||||||
|
courseDetails.push({
|
||||||
|
id: course.id,
|
||||||
|
title: course.title.th,
|
||||||
|
students: 0,
|
||||||
|
lessons: 0,
|
||||||
|
icon: 'book',
|
||||||
|
thumbnail: course.thumbnail_url || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
this.stats.totalCourses = courses.length;
|
this.stats.totalCourses = courses.length;
|
||||||
// TODO: Get real student counts from API when available
|
this.stats.totalStudents = totalStudents;
|
||||||
this.stats.totalStudents = 0;
|
this.stats.completedStudents = completedStudents;
|
||||||
this.stats.completedStudents = 0;
|
|
||||||
|
|
||||||
// Map to recent courses format (take first 5)
|
// Update course status counts
|
||||||
this.recentCourses = courses.slice(0, 3).map((course, index) => ({
|
this.courseStatusCounts = {
|
||||||
id: course.id,
|
approved: courses.filter(c => c.status === 'APPROVED').length,
|
||||||
title: course.title.th,
|
pending: courses.filter(c => c.status === 'PENDING').length,
|
||||||
students: 0, // TODO: Get from API
|
draft: courses.filter(c => c.status === 'DRAFT').length,
|
||||||
lessons: 0, // TODO: Get from course detail API
|
rejected: courses.filter(c => c.status === 'REJECTED').length
|
||||||
icon: 'book',
|
};
|
||||||
thumbnail: course.thumbnail_url || null
|
|
||||||
}));
|
// Update recent courses (first 3)
|
||||||
|
this.recentCourses = courseDetails.slice(0, 3);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch dashboard data:', error);
|
console.error('Failed to fetch dashboard data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue