elearning/frontend_management/pages/admin/users/index.vue
Missez e57630ac05
All checks were successful
Build and Deploy Frontend Learner / Build Frontend Learner Docker Image (push) Successful in 35s
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 34s
Build and Deploy Frontend Learner / Deploy E-learning Frontend Learner to Dev Server (push) Successful in 3s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 3s
Build and Deploy Frontend Learner / Notify Deployment Status (push) Successful in 1s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 2s
feat: add Dockerfile for frontend management and implement an admin page for user management.
2026-02-10 16:56:34 +07:00

420 lines
14 KiB
Vue

<template>
<div>
<!-- Header -->
<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
outline
color="red"
label="ส่งออก Excel"
icon="download"
@click="exportExcel"
/> -->
<!-- <q-btn
color="primary"
label="+ เพิ่มผู้ใช้ใหม่"
@click="showAddModal = true"
/> -->
</div>
</div>
<!-- Search and Filter -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-3">
<q-input
v-model="searchQuery"
placeholder="ค้นหาชื่อ, อีเมล, username..."
outlined
dense
bg-color="grey-1"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
<q-select
v-model="filterRole"
:options="roleOptions"
outlined
dense
emit-value
map-options
/>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-primary-600">{{ stats.total.toLocaleString() }}</div>
<div class="text-gray-500 mt-1">ผู้ใช้ทั้งหมด</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-gray-700">{{ stats.admin }}</div>
<div class="text-gray-500 mt-1">Admin</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-gray-700">{{ stats.instructor }}</div>
<div class="text-gray-500 mt-1">Instructor</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-gray-700">{{ stats.student.toLocaleString() }}</div>
<div class="text-gray-500 mt-1">Student</div>
</div>
</div>
<!-- Users Table -->
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<q-table
:rows="filteredUsers"
:columns="columns"
row-key="id"
:loading="loading"
flat
:pagination="pagination"
:rows-per-page-options="[5, 10, 20, 50, 0]"
@update:pagination="pagination = $event"
>
<!-- User Info -->
<template v-slot:body-cell-user="props">
<q-td :props="props">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full overflow-hidden flex items-center justify-center bg-primary-100">
<img
v-if="props.row.avatar_url || props.row.profile?.avatar_url"
:src="props.row.avatar_url || props.row.profile?.avatar_url"
class="w-full h-full object-cover"
alt="Avatar"
/>
<q-icon v-else name="person" color="primary" />
</div>
<div>
<div class="font-medium text-primary-600 hover:underline cursor-pointer">
{{ getFullName(props.row) }}
</div>
<div class="text-sm text-gray-500">{{ props.row.email }}</div>
</div>
</div>
</q-td>
</template>
<!-- Role Badge -->
<template v-slot:body-cell-role="props">
<q-td :props="props">
<q-badge
:color="getRoleBadgeColor(props.row.role.code)"
:label="props.row.role.name.th"
class="px-3 py-1"
/>
</q-td>
</template>
<!-- Date -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.row.created_at) }}
</q-td>
</template>
<!-- Actions -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn flat round dense icon="more_vert">
<q-menu>
<q-list style="min-width: 150px">
<q-item clickable v-close-popup @click="viewUser(props.row)">
<q-item-section avatar>
<q-icon name="visibility" color="grey" />
</q-item-section>
<q-item-section></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="changeRole(props.row)">
<q-item-section avatar>
<q-icon name="swap_horiz" color="primary" />
</q-item-section>
<q-item-section>แก Role</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="confirmDelete(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ลบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
</div>
<!-- View User Modal -->
<q-dialog v-model="showViewModal">
<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="showViewModal = false" />
</q-card-section>
<q-card-section v-if="selectedUser">
<!-- Avatar & Name -->
<div class="flex items-center gap-4 mb-6">
<div class="w-20 h-20 rounded-full overflow-hidden flex items-center justify-center bg-primary-100 text-3xl">
<img
v-if="selectedUser.avatar_url || selectedUser.profile?.avatar_url"
:src="selectedUser.avatar_url || selectedUser.profile?.avatar_url"
class="w-full h-full object-cover"
alt="Avatar"
/>
<q-icon v-else name="person" color="primary" size="40px" />
</div>
<div>
<div class="text-xl font-bold text-gray-900">
{{ selectedUser.profile.prefix?.th }}{{ selectedUser.profile.first_name }} {{ selectedUser.profile.last_name }}
</div>
<div class="text-gray-500">@{{ selectedUser.username }}</div>
<q-badge :color="getRoleBadgeColor(selectedUser.role.code)" class="mt-1">
{{ selectedUser.role.name.th }}
</q-badge>
</div>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-500">เมล</div>
<div class="font-medium">{{ selectedUser.email }}</div>
</div>
<div>
<div class="text-sm text-gray-500">เบอรโทร</div>
<div class="font-medium">{{ selectedUser.profile.phone || '-' }}</div>
</div>
<div>
<div class="text-sm text-gray-500">นทลงทะเบยน</div>
<div class="font-medium">{{ formatDate(selectedUser.created_at) }}</div>
</div>
<div>
<div class="text-sm text-gray-500">พเดทลาส</div>
<div class="font-medium">{{ formatDate(selectedUser.updated_at) }}</div>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="ปิด" color="primary" @click="showViewModal = false" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { adminService, type AdminUserResponse } from '~/services/admin.service';
import { useAuthStore } from '~/stores/auth';
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
});
const $q = useQuasar();
// Data
const users = ref<AdminUserResponse[]>([]);
const loading = ref(true);
const searchQuery = ref('');
const filterRole = ref<string | null>(null);
const showAddModal = ref(false);
const showViewModal = ref(false);
const selectedUser = ref<AdminUserResponse | null>(null);
const pagination = ref({
page: 1,
rowsPerPage: 10
});
// Table columns
const columns = [
{ name: 'user', label: 'ผู้ใช้งาน', field: 'user', align: 'left' as const },
{ name: 'role', label: 'บทบาท', field: 'role', align: 'center' as const },
{ name: 'created_at', label: 'ลงทะเบียน', field: 'created_at', align: 'center' as const },
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const }
];
// Filter options
const roleOptions = [
{ label: 'บทบาททั้งหมด', value: null },
{ label: 'Instructor', value: 'INSTRUCTOR' },
{ label: 'Student', value: 'STUDENT' },
{ label: 'Admin', value: 'ADMIN' }
]
// Stats computed
const stats = computed(() => {
const total = users.value.length;
const admin = users.value.filter(u => u.role.code === 'ADMIN').length;
const instructor = users.value.filter(u => u.role.code === 'INSTRUCTOR').length;
const student = users.value.filter(u => u.role.code === 'STUDENT').length;
return { total, admin, instructor, student };
});
// Filtered users
const filteredUsers = computed(() => {
let result = users.value;
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(user =>
user.profile.first_name.toLowerCase().includes(query) ||
user.profile.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.username.toLowerCase().includes(query)
);
}
if (filterRole.value) {
result = result.filter(user => user.role.code === filterRole.value);
}
return result;
});
// Methods
const fetchUsers = async () => {
loading.value = true;
try {
users.value = await adminService.getUsers();
} catch (error) {
$q.notify({
type: 'negative',
message: (error as any).data?.message || 'ไม่สามารถโหลดข้อมูลผู้ใช้ได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
const getFullName = (user: AdminUserResponse) => {
const prefix = user.profile.prefix?.th || '';
return `${prefix}${user.profile.first_name} ${user.profile.last_name}`;
};
const getRoleBadgeColor = (roleCode: string) => {
const colors: Record<string, string> = {
ADMIN: 'red',
INSTRUCTOR: 'orange',
STUDENT: 'blue'
};
return colors[roleCode] || 'grey';
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
const viewUser = (user: AdminUserResponse) => {
selectedUser.value = user;
showViewModal.value = true;
};
const changeRole = (user: AdminUserResponse) => {
const roleIds: Record<string, number> = {
INSTRUCTOR: 1,
STUDENT: 2,
ADMIN: 3
};
$q.dialog({
title: 'เปลี่ยน Role',
message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`,
options: {
type: 'radio',
model: roleIds[user.role.code] as any,
items: [
{ label: 'Instructor', value: 1 },
{ label: 'Student', value: 2 },
{ label: 'Admin', value: 3 }
]
},
cancel: true,
persistent: true
}).onOk(async (roleId: number) => {
try {
const response = await adminService.updateUserRole(user.id, roleId);
$q.notify({
type: 'positive',
message: response.message || 'เปลี่ยน Role สำเร็จ',
position: 'top'
});
fetchUsers();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.message || 'เกิดข้อผิดพลาดในการเปลี่ยน Role',
position: 'top'
});
}
});
};
const authStore = useAuthStore();
const confirmDelete = (user: AdminUserResponse) => {
// Prevent deleting own account
if (authStore.user && Number(authStore.user.id) === user.id) {
$q.notify({
type: 'warning',
message: 'ไม่สามารถลบบัญชีของตัวเองได้',
position: 'top'
});
return;
}
$q.dialog({
title: 'ยืนยันการลบ',
message: `คุณต้องการลบผู้ใช้ "${user.profile.first_name} ${user.profile.last_name}" หรือไม่?`,
cancel: true,
persistent: true
}).onOk(async () => {
try {
const response = await adminService.deleteUser(user.id);
$q.notify({
type: 'positive',
message: response.message || 'ลบผู้ใช้สำเร็จ',
position: 'top'
});
fetchUsers();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.message || 'เกิดข้อผิดพลาดในการลบผู้ใช้',
position: 'top'
});
}
});
};
const exportExcel = () => {
$q.notify({
type: 'info',
message: 'กำลังส่งออกไฟล์ Excel...',
position: 'top'
});
};
// Lifecycle
onMounted(() => {
fetchUsers();
});
</script>