feat: del mock , crud add instructor , VerifyEmail

This commit is contained in:
Missez 2026-02-03 11:55:26 +07:00
parent b2365a4c6a
commit 278bc17fa0
8 changed files with 345 additions and 1566 deletions

View file

@ -193,7 +193,7 @@
<q-tab-panel name="instructors" class="p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-900">สอนในรายวชา</h2>
<q-btn color="primary" icon="person_add" label="เพิ่มผู้สอน" @click="showAddInstructorDialog = true" />
<q-btn v-if="isPrimaryInstructor" color="primary" icon="person_add" label="เพิ่มผู้สอน" @click="showAddInstructorDialog = true" />
</div>
<div v-if="loadingInstructors" class="flex justify-center py-10">
@ -223,7 +223,7 @@
<q-item-label caption>{{ instructor.user.email }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-section side v-if="isPrimaryInstructor">
<q-btn flat dense round icon="more_vert">
<q-menu>
<q-item v-if="!instructor.is_primary" clickable v-close-popup @click="setPrimaryInstructor(instructor.user_id)">
@ -469,14 +469,15 @@
<q-card-section>
<q-select
v-model="selectedUser"
:options="filteredUsers"
:options="searchResults"
option-value="id"
option-label="email"
label="ค้นหาผู้ใช้ (Email หรือ Username)"
label="ค้นหาผู้สอน (Email หรือ Username)"
hint="พิมพ์อย่างน้อย 2 ตัวอักษรเพื่อค้นหา"
use-input
filled
@filter="filterUsers"
:loading="loadingUsers"
:loading="loadingSearch"
>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
@ -521,9 +522,9 @@ import {
type ChapterResponse,
type AnnouncementResponse,
type CreateAnnouncementRequest,
type CourseInstructorResponse
type CourseInstructorResponse,
type SearchInstructorResult
} from '~/services/instructor.service';
import { adminService, type AdminUserResponse } from '~/services/admin.service';
definePageMeta({
layout: 'instructor',
@ -532,6 +533,7 @@ definePageMeta({
const $q = useQuasar();
const route = useRoute();
const authStore = useAuthStore();
// Data
const course = ref<CourseDetailResponse | null>(null);
@ -559,11 +561,11 @@ const announcementForm = ref<CreateAnnouncementRequest>({
const instructors = ref<CourseInstructorResponse[]>([]);
const loadingInstructors = ref(false);
const showAddInstructorDialog = ref(false);
const selectedUser = ref<AdminUserResponse | null>(null);
const users = ref<AdminUserResponse[]>([]);
const filteredUsers = ref<AdminUserResponse[]>([]);
const loadingUsers = ref(false);
const selectedUser = ref<SearchInstructorResult | null>(null);
const searchResults = ref<SearchInstructorResult[]>([]);
const loadingSearch = ref(false);
const addingInstructor = ref(false);
const searchQuery = ref('');
// Attachment handling
const fileInputRef = ref<HTMLInputElement | null>(null);
@ -582,6 +584,14 @@ const sortedChapters = computed(() => {
return course.value.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
});
// Check if current user is the primary instructor
const isPrimaryInstructor = computed(() => {
if (!authStore.user?.id) return false;
const currentUserId = parseInt(authStore.user.id);
const myInstructorRecord = instructors.value.find(i => i.user_id === currentUserId);
return myInstructorRecord?.is_primary === true;
});
// Methods
const fetchCourse = async () => {
loading.value = true;
@ -757,31 +767,40 @@ const fetchInstructors = async () => {
}
};
const filterUsers = async (val: string, update: (callback: () => void) => void) => {
if (users.value.length === 0) {
loadingUsers.value = true;
try {
users.value = await adminService.getUsers();
} catch (error) {
console.error('Failed to load users', error);
} finally {
loadingUsers.value = false;
}
let searchTimeout: NodeJS.Timeout | null = null;
const filterUsers = (val: string, update: (callback: () => void) => void, abort: () => void) => {
// Abort if query is too short
if (val.length < 2) {
abort();
return;
}
update(() => {
const needle = val.toLowerCase();
const existingInstructorIds = instructors.value.map(i => i.user_id);
filteredUsers.value = users.value.filter(v => {
// Exclude existing instructors
if (existingInstructorIds.includes(v.id)) return false;
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Debounce the search
searchTimeout = setTimeout(async () => {
loadingSearch.value = true;
try {
const results = await instructorService.searchInstructors(val);
const existingInstructorIds = instructors.value.map(i => i.user_id);
// Filter by username or email
return v.username.toLowerCase().indexOf(needle) > -1 ||
v.email.toLowerCase().indexOf(needle) > -1;
});
});
// Filter out existing instructors
update(() => {
searchResults.value = results.filter(r => !existingInstructorIds.includes(r.id));
});
} catch (error) {
console.error('Failed to search instructors', error);
update(() => {
searchResults.value = [];
});
} finally {
loadingSearch.value = false;
}
}, 300);
};
const addInstructor = async () => {
@ -790,7 +809,7 @@ const addInstructor = async () => {
addingInstructor.value = true;
try {
const courseId = parseInt(route.params.id as string);
const response = await instructorService.addInstructor(courseId, selectedUser.value.id);
const response = await instructorService.addInstructor(courseId, selectedUser.value.email);
$q.notify({
type: 'positive',

View file

@ -50,7 +50,11 @@
<div>
<div class="text-sm text-gray-600 mb-1">เมล</div>
<div class="text-lg text-gray-900">{{ profile.email }}</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>
@ -75,7 +79,7 @@
</div>
<!-- Action Buttons -->
<div class="flex gap-3 mt-6">
<div class="flex flex-wrap gap-3 mt-6">
<q-btn
color="primary"
label="แก้ไขโปรไฟล์"
@ -89,6 +93,15 @@
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>
@ -289,6 +302,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { authService } from '~/services/auth.service';
definePageMeta({
layout: 'instructor',
@ -305,11 +319,12 @@ const loading = ref(true);
const profile = ref({
fullName: '',
email: '',
emailVerified: false,
username: '',
phone: '',
role: '',
roleName: '',
avatar: '👨‍🏫',
avatar: '',
avatarUrl: '' as string | null,
createdAt: ''
});
@ -340,6 +355,9 @@ const passwordForm = ref({
confirmPassword: ''
});
// Email verification
const sendingVerification = ref(false);
// Methods
const getRoleLabel = (role: string) => {
const labels: Record<string, string> = {
@ -495,6 +513,26 @@ const handleChangePassword = async () => {
}
};
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) {
@ -518,11 +556,12 @@ const fetchProfile = async () => {
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: '👨‍🏫',
avatar: '',
avatarUrl: data.profile.avatar_url,
createdAt: data.created_at
};