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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue