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

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

View file

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

View file

@ -40,6 +40,7 @@
row-key="id"
:loading="loading"
flat
:rows-per-page-options="[5, 10, 20, 50, 0]"
bordered
>
<!-- Name -->

View file

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

View 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>

View file

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

View file

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

View file

@ -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({

View file

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

View file

@ -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 {