feat: Implement user authentication, admin user management, and role-based access control.
This commit is contained in:
parent
8a2ca592bc
commit
38648581ec
19 changed files with 1762 additions and 514 deletions
328
frontend_management/pages/admin/categories/index.vue
Normal file
328
frontend_management/pages/admin/categories/index.vue
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary-600">จัดการหมวดหมู่</h1>
|
||||
<p class="text-gray-600 mt-1">จัดการหมวดหมู่หลักสูตรในระบบ</p>
|
||||
</div>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="+ เพิ่มหมวดหมู่ใหม่"
|
||||
@click="openAddModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table Card -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-primary-600 mb-4">รายการหมวดหมู่</h2>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4" style="max-width: 400px">
|
||||
<q-input
|
||||
v-model="searchQuery"
|
||||
placeholder="ค้นหาหมวดหมู่..."
|
||||
outlined
|
||||
dense
|
||||
bg-color="grey-1"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<q-table
|
||||
:rows="filteredCategories"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<!-- Name -->
|
||||
<template v-slot:body-cell-name="props">
|
||||
<q-td :props="props">
|
||||
<div class="font-medium">{{ props.row.name.th }}</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- Description -->
|
||||
<template v-slot:body-cell-description="props">
|
||||
<q-td :props="props">
|
||||
<div class="text-gray-600">{{ props.row.description.th }}</div>
|
||||
</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">
|
||||
<div class="flex gap-2">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="edit"
|
||||
color="warning"
|
||||
@click="openEditModal(props.row)"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="delete"
|
||||
color="negative"
|
||||
@click="confirmDelete(props.row)"
|
||||
/>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<q-dialog v-model="showModal" persistent>
|
||||
<q-card style="min-width: 500px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">{{ isEditing ? 'แก้ไขหมวดหมู่' : 'เพิ่มหมวดหมู่ใหม่' }}</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense @click="showModal = false" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit="handleSave">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<q-input
|
||||
v-model="form.name.th"
|
||||
label="ชื่อ (ภาษาไทย)"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.name.en"
|
||||
label="ชื่อ (English)"
|
||||
outlined
|
||||
:rules="[val => !!val || 'Please enter name']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
v-model="form.slug"
|
||||
label="Slug (URL)"
|
||||
outlined
|
||||
class="mb-4"
|
||||
hint="ใช้สำหรับ URL เช่น web-development"
|
||||
:rules="[val => !!val || 'กรุณากรอก slug']"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="form.description.th"
|
||||
label="คำอธิบาย (ภาษาไทย)"
|
||||
type="textarea"
|
||||
outlined
|
||||
class="mb-4"
|
||||
autogrow
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="form.description.en"
|
||||
label="คำอธิบาย (English)"
|
||||
type="textarea"
|
||||
outlined
|
||||
class="mb-4"
|
||||
autogrow
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<q-btn
|
||||
flat
|
||||
label="ยกเลิก"
|
||||
color="grey-7"
|
||||
@click="showModal = false"
|
||||
/>
|
||||
<q-btn
|
||||
type="submit"
|
||||
:label="isEditing ? 'บันทึก' : 'เพิ่ม'"
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { adminService, type CategoryResponse } from '~/services/admin.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: ['auth', 'admin']
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Data
|
||||
const categories = ref<CategoryResponse[]>([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const showModal = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
|
||||
// Form
|
||||
const form = ref({
|
||||
name: { th: '', en: '' },
|
||||
slug: '',
|
||||
description: { th: '', en: '' }
|
||||
});
|
||||
|
||||
// Table columns
|
||||
const columns = [
|
||||
{ name: 'id', label: 'ID', field: 'id', align: 'center' as const, style: 'width: 60px' },
|
||||
{ name: 'name', label: 'ชื่อหมวดหมู่', field: 'name', align: 'left' as const },
|
||||
{ name: 'description', label: 'คำอธิบาย', field: 'description', align: 'left' as const },
|
||||
{ name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const },
|
||||
{ name: 'actions', label: 'การจัดการ', field: 'actions', align: 'center' as const, style: 'width: 120px' }
|
||||
];
|
||||
|
||||
// Computed
|
||||
const filteredCategories = computed(() => {
|
||||
if (!searchQuery.value) return categories.value;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return categories.value.filter(cat =>
|
||||
cat.name.th.toLowerCase().includes(query) ||
|
||||
cat.name.en.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const fetchCategories = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
categories.value = await adminService.getCategories();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูลหมวดหมู่ได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('th-TH', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
name: { th: '', en: '' },
|
||||
slug: '',
|
||||
description: { th: '', en: '' }
|
||||
};
|
||||
editingId.value = null;
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
resetForm();
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const openEditModal = (category: CategoryResponse) => {
|
||||
isEditing.value = true;
|
||||
editingId.value = category.id;
|
||||
form.value = {
|
||||
name: { ...category.name },
|
||||
slug: category.slug,
|
||||
description: { ...category.description }
|
||||
};
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditing.value && editingId.value) {
|
||||
await adminService.updateCategory(editingId.value, {
|
||||
id: editingId.value,
|
||||
...form.value
|
||||
});
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'แก้ไขหมวดหมู่สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
} else {
|
||||
await adminService.createCategory({
|
||||
...form.value,
|
||||
created_by: parseInt(authStore.user?.id || '0')
|
||||
});
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'เพิ่มหมวดหมู่สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
showModal.value = false;
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (category: CategoryResponse) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`,
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await adminService.deleteCategory(category.id);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ลบหมวดหมู่สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการลบ',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
394
frontend_management/pages/admin/users/index.vue
Normal file
394
frontend_management/pages/admin/users/index.vue
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<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"
|
||||
@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 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<q-icon 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 bg-primary-100 rounded-full flex items-center justify-center text-3xl">
|
||||
<q-icon 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';
|
||||
|
||||
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: 'ไม่สามารถโหลดข้อมูลผู้ใช้ได้',
|
||||
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],
|
||||
items: [
|
||||
{ label: 'Instructor', value: 1 },
|
||||
{ label: 'Student', value: 2 },
|
||||
{ label: 'Admin', value: 3 }
|
||||
]
|
||||
},
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async (roleId: number) => {
|
||||
try {
|
||||
await adminService.updateUserRole(user.id, roleId);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'เปลี่ยน Role สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการเปลี่ยน Role',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (user: AdminUserResponse) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
message: `คุณต้องการลบผู้ใช้ "${user.profile.first_name} ${user.profile.last_name}" หรือไม่?`,
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await adminService.deleteUser(user.id);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ลบผู้ใช้สำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการลบผู้ใช้',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const exportExcel = () => {
|
||||
$q.notify({
|
||||
type: 'info',
|
||||
message: 'กำลังส่งออกไฟล์ Excel...',
|
||||
position: 'top'
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -5,13 +5,13 @@
|
|||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">
|
||||
สวัสดี, {{ authStore.user?.fullName || 'อาจารย์' }}
|
||||
สวัสดี, {{ authStore.user?.firstName }} {{ authStore.user?.lastName || 'อาจารย์' }}
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.fullName || 'อาจารย์ทดสอบ' }}</div>
|
||||
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.firstName }} {{ authStore.user?.lastName || 'อาจารย์ทดสอบ' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
<!-- User Info Header -->
|
||||
<q-item class="bg-primary-50">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-bold">{{ authStore.user?.fullName }}</q-item-label>
|
||||
<q-item-label class="text-weight-bold">{{ authStore.user?.firstName }} {{ authStore.user?.lastName }}</q-item-label>
|
||||
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Profile Card -->
|
||||
<AppCard class="mb-10 ">
|
||||
<div class="mb-10 ">
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex flex-col items-center">
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
<div>
|
||||
<div class="text-sm text-gray-600 mb-1">ตำแหน่ง</div>
|
||||
<div class="text-lg text-gray-900">
|
||||
<q-badge color="primary">{{ getRoleLabel(profile.role) }}</q-badge>
|
||||
<q-badge color="primary">{{ profile.roleName || getRoleLabel(profile.role) }}</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5">
|
||||
|
|
@ -108,11 +108,11 @@
|
|||
<q-card-section>
|
||||
<q-form @submit="handleUpdateProfile">
|
||||
<q-input
|
||||
v-model="editForm.fullName"
|
||||
label="ชื่อ-นามสกุล"
|
||||
v-model="editForm.firstName"
|
||||
label="ชื่อ"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อ-นามสกุล']"
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
|
|
@ -120,27 +120,14 @@
|
|||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="editForm.email"
|
||||
label="อีเมล"
|
||||
type="email"
|
||||
v-model="editForm.lastName"
|
||||
label="นามสกุล"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
||||
:rules="[val => !!val || 'กรุณากรอกนามสกุล']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="email" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="editForm.username"
|
||||
label="Username"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอก username']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="account_circle" />
|
||||
<q-icon name="person" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
|
|
@ -274,6 +261,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { userService, type UserProfileResponse } from '~/services/user.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'instructor',
|
||||
|
|
@ -283,15 +271,19 @@ definePageMeta({
|
|||
const $q = useQuasar();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Loading state
|
||||
const loading = ref(true);
|
||||
|
||||
// Profile data
|
||||
const profile = ref({
|
||||
fullName: 'อาจารย์ทดสอบ ระบบอีเลินนิ่ง',
|
||||
email: 'instructor@example.com',
|
||||
username: 'instructor_test',
|
||||
phone: '081-234-5678',
|
||||
role: 'INSTRUCTOR',
|
||||
fullName: '',
|
||||
email: '',
|
||||
username: '',
|
||||
phone: '',
|
||||
role: '',
|
||||
roleName: '',
|
||||
avatar: '👨🏫',
|
||||
createdAt: '2024-01-01'
|
||||
createdAt: ''
|
||||
});
|
||||
|
||||
const stats = ref({
|
||||
|
|
@ -303,9 +295,8 @@ const stats = ref({
|
|||
const showEditModal = ref(false);
|
||||
const saving = ref(false);
|
||||
const editForm = ref({
|
||||
fullName: '',
|
||||
email: '',
|
||||
username: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
|
|
@ -351,11 +342,15 @@ const handleUpdateProfile = async () => {
|
|||
saving.value = true;
|
||||
|
||||
try {
|
||||
// TODO: Call API to update profile
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// Call real API to update profile
|
||||
await userService.updateProfile({
|
||||
first_name: editForm.value.firstName,
|
||||
last_name: editForm.value.lastName,
|
||||
phone: editForm.value.phone || null
|
||||
});
|
||||
|
||||
// Update local data
|
||||
profile.value = { ...profile.value, ...editForm.value };
|
||||
// Refresh profile data from API
|
||||
await fetchProfile();
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
|
|
@ -364,7 +359,7 @@ const handleUpdateProfile = async () => {
|
|||
});
|
||||
|
||||
showEditModal.value = false;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
|
||||
|
|
@ -379,8 +374,11 @@ const handleChangePassword = async () => {
|
|||
changingPassword.value = true;
|
||||
|
||||
try {
|
||||
// TODO: Call API to change password
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// Call real API to change password
|
||||
await userService.changePassword(
|
||||
passwordForm.value.currentPassword,
|
||||
passwordForm.value.newPassword
|
||||
);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
|
|
@ -394,10 +392,12 @@ const handleChangePassword = async () => {
|
|||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'รหัสผ่านปัจจุบันไม่ถูกต้อง',
|
||||
message: error.response?.status === 401
|
||||
? 'รหัสผ่านปัจจุบันไม่ถูกต้อง'
|
||||
: 'เกิดข้อผิดพลาดในการเปลี่ยนรหัสผ่าน',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -408,12 +408,46 @@ const handleChangePassword = async () => {
|
|||
// Watch edit modal
|
||||
watch(showEditModal, (newVal) => {
|
||||
if (newVal) {
|
||||
// Split fullName into firstName and lastName for editing
|
||||
const nameParts = profile.value.fullName.split(' ');
|
||||
editForm.value = {
|
||||
fullName: profile.value.fullName,
|
||||
email: profile.value.email,
|
||||
username: profile.value.username,
|
||||
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,
|
||||
username: data.username,
|
||||
phone: data.profile.phone || '',
|
||||
role: data.role.code,
|
||||
roleName: data.role.name.th,
|
||||
avatar: 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>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@
|
|||
|
||||
<div class="flex items-center justify-between">
|
||||
<q-checkbox v-model="rememberMe" label="จดจำฉันไว้" />
|
||||
<a href="#" class="text-sm text-primary-600 hover:text-primary-700">ลืมรหัสผ่าน?</a>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-primary-600 hover:text-primary-700"
|
||||
@click.prevent="showForgotModal = true"
|
||||
>ลืมรหัสผ่าน?</a>
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
|
|
@ -54,22 +58,78 @@
|
|||
<p>ทดสอบ: admin@elearning.local / instructor@elearning.local</p>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Forgot Password Modal -->
|
||||
<q-dialog v-model="showForgotModal" persistent>
|
||||
<q-card style="min-width: 400px">
|
||||
<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="showForgotModal = false" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<p class="text-gray-600 mb-4">
|
||||
กรอกอีเมลของคุณ เราจะส่งลิงก์สำหรับรีเซ็ตรหัสผ่านไปให้
|
||||
</p>
|
||||
<q-form @submit="handleForgotPassword">
|
||||
<q-input
|
||||
v-model="forgotEmail"
|
||||
label="อีเมล"
|
||||
type="email"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="email" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<q-btn
|
||||
flat
|
||||
label="ยกเลิก"
|
||||
color="grey-7"
|
||||
@click="showForgotModal = false"
|
||||
/>
|
||||
<q-btn
|
||||
type="submit"
|
||||
label="ส่งลิงก์รีเซ็ต"
|
||||
color="primary"
|
||||
:loading="forgotLoading"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { authService } from '~/services/auth.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth'
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Login form
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
const rememberMe = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
// Forgot password
|
||||
const showForgotModal = ref(false);
|
||||
const forgotEmail = ref('');
|
||||
const forgotLoading = ref(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
|
@ -86,14 +146,38 @@ const handleLogin = async () => {
|
|||
} else if (authStore.isAdmin) {
|
||||
router.push('/admin');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
|
||||
message: error.message || 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
forgotLoading.value = true;
|
||||
try {
|
||||
await authService.forgotPassword(forgotEmail.value);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ส่งลิงก์รีเซ็ตรหัสผ่านไปยังอีเมลของคุณแล้ว',
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
showForgotModal.value = false;
|
||||
forgotEmail.value = '';
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
forgotLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
138
frontend_management/pages/reset-password.vue
Normal file
138
frontend_management/pages/reset-password.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<div class="p-6 items-center justify-center">
|
||||
<q-card-section>
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">E-Learning</h1>
|
||||
<p class="text-gray-600 mt-2">ตั้งรหัสผ่านใหม่</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="success" class="text-center">
|
||||
<q-icon name="check_circle" size="80px" color="positive" class="mb-4" />
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">รีเซ็ตรหัสผ่านสำเร็จ!</h2>
|
||||
<p class="text-gray-600 mb-6">คุณสามารถเข้าสู่ระบบด้วยรหัสผ่านใหม่ได้แล้ว</p>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="ไปหน้าเข้าสู่ระบบ"
|
||||
@click="router.push('/login')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Reset Form -->
|
||||
<div v-else>
|
||||
<q-form @submit="handleResetPassword" class="space-y-4">
|
||||
<q-input
|
||||
v-model="password"
|
||||
label="รหัสผ่านใหม่"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
outlined
|
||||
:rules="[
|
||||
val => !!val || 'กรุณากรอกรหัสผ่าน',
|
||||
val => val.length >= 8 || 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร'
|
||||
]"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="confirmPassword"
|
||||
label="ยืนยันรหัสผ่านใหม่"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
outlined
|
||||
:rules="[
|
||||
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
||||
val => val === password || 'รหัสผ่านไม่ตรงกัน'
|
||||
]"
|
||||
>
|
||||
<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>
|
||||
|
||||
<q-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
label="ตั้งรหัสผ่านใหม่"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
/>
|
||||
</q-form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700">
|
||||
กลับหน้าเข้าสู่ระบบ
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { authService } from '~/services/auth.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth'
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// Form
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const loading = ref(false);
|
||||
const success = ref(false);
|
||||
|
||||
// Get token from URL
|
||||
const token = computed(() => route.query.token as string);
|
||||
|
||||
// Check if token exists
|
||||
onMounted(() => {
|
||||
if (!token.value) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ลิงก์รีเซ็ตรหัสผ่านไม่ถูกต้อง',
|
||||
position: 'top'
|
||||
});
|
||||
router.push('/login');
|
||||
}
|
||||
});
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await authService.resetPassword(token.value, password.value);
|
||||
success.value = true;
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue