feat: Implement admin user and course management, instructor course and quiz management

This commit is contained in:
Missez 2026-02-11 17:03:43 +07:00
parent 8edc3770eb
commit a65ded02f9
7 changed files with 156 additions and 7 deletions

View file

@ -44,5 +44,4 @@ ENV PORT=3001
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3001
# Start the application using preview command
CMD ["node", ".output/server/index.mjs"]

View file

@ -0,0 +1,104 @@
<template>
<div class="h-full">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<q-spinner-dots size="40px" color="primary" />
</div>
<!-- Empty State -->
<div v-else-if="history.length === 0" class="text-center py-10 text-gray-500">
<div class="flex flex-col items-center">
<q-icon name="history" size="40px" class="mb-2" />
<p>ไมพบประวการขออน</p>
</div>
</div>
<!-- Timeline -->
<div v-else class="px-4 py-2">
<q-timeline color="primary">
<q-timeline-entry
v-for="item in history"
:key="item.id"
:title="titleMap[item.action] || item.action"
:subtitle="formatDate(item.created_at)"
:color="colorMap[item.action] || 'grey'"
:icon="iconMap[item.action] || 'circle'"
>
<div class="text-gray-600">
<div class="font-medium text-gray-900 mb-1">
โดย: {{ getActorName(item) }}
</div>
<div v-if="item.comment" class="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-100 italic">
"{{ item.comment }}"
</div>
</div>
</q-timeline-entry>
</q-timeline>
</div>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { instructorService, type ApprovalHistory } from '~/services/instructor.service';
const props = defineProps<{
courseId: number;
}>();
const $q = useQuasar();
const loading = ref(true);
const history = ref<ApprovalHistory[]>([]);
const titleMap: Record<string, string> = {
SUBMITTED: 'ส่งขออนุมัติ',
APPROVED: 'อนุมัติแล้ว',
REJECTED: 'ไม่อนุมัติ'
};
const colorMap: Record<string, string> = {
SUBMITTED: 'orange',
APPROVED: 'green',
REJECTED: 'red'
};
const iconMap: Record<string, string> = {
SUBMITTED: 'send',
APPROVED: 'check_circle',
REJECTED: 'cancel'
};
const fetchHistory = async () => {
loading.value = true;
try {
history.value = await instructorService.getCourseApprovalHistory(props.courseId);
} catch (error: any) {
console.error('Failed to fetch approval history:', error);
$q.notify({
type: 'negative',
message: error.data?.message || 'ไม่สามารถโหลดประวัติการอนุมัติได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
const getActorName = (item: ApprovalHistory) => {
const actor = item.action === 'SUBMITTED' ? item.submitter : item.reviewer;
if (!actor) return 'System/Admin';
return actor.username || actor.email || 'Unknown User';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('th-TH', {
dateStyle: 'medium',
timeStyle: 'short'
});
};
onMounted(() => {
fetchHistory();
});
</script>

View file

@ -119,7 +119,7 @@
<!-- Timeline -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="font-semibold text-gray-700 mb-4">ไทมไลน</h3>
<h3 class="font-semibold text-gray-700 mb-4">อมลระบบ</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">สรางเม</span>

View file

@ -86,7 +86,7 @@
<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"
:src="props.row.avatar_url || props.row.profile?.avatar_url || undefined"
class="w-full h-full object-cover"
alt="Avatar"
/>
@ -168,7 +168,7 @@
<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"
:src="selectedUser.avatar_url || selectedUser.profile?.avatar_url || undefined"
class="w-full h-full object-cover"
alt="Avatar"
/>

View file

@ -121,6 +121,10 @@
v-model="quizSettings.is_skippable"
label="ทำแบบทดสอบข้ามได้"
/>
<q-toggle
v-model="quizSettings.allow_multiple_attempts"
label="อนุญาตให้ทำซ้ำได้หลายรอบ"
/>
</div>
<div class="mt-4 text-right">
<q-btn
@ -383,7 +387,8 @@ const quizSettings = ref({
shuffle_questions: false,
shuffle_choices: false,
show_answers_after_completion: true,
is_skippable: false
is_skippable: false,
allow_multiple_attempts: false
});
const savingSettings = ref(false);
@ -399,7 +404,8 @@ const saveQuizSettings = async () => {
shuffle_questions: quizSettings.value.shuffle_questions,
shuffle_choices: quizSettings.value.shuffle_choices,
show_answers_after_completion: quizSettings.value.show_answers_after_completion,
is_skippable: quizSettings.value.is_skippable
is_skippable: quizSettings.value.is_skippable,
allow_multiple_attempts: quizSettings.value.allow_multiple_attempts
});
$q.notify({ type: 'positive', message: response.message, position: 'top' });
} catch (error) {
@ -509,7 +515,8 @@ const fetchLesson = async () => {
shuffle_questions: data.quiz.shuffle_questions || false,
shuffle_choices: data.quiz.shuffle_choices || false,
show_answers_after_completion: data.quiz.show_answers_after_completion !== false,
is_skippable: data.quiz.is_skippable || false
is_skippable: data.quiz.is_skippable || false,
allow_multiple_attempts: data.quiz.allow_multiple_attempts || false
};
// Load questions from API

View file

@ -110,6 +110,7 @@
<q-tab name="students" icon="people" label="ผู้เรียน" />
<q-tab name="instructors" icon="manage_accounts" label="ผู้สอน" />
<q-tab name="quiz" icon="quiz" label="ผลการทดสอบ" />
<q-tab name="history" icon="history" label="ประวัติการขออนุมัติ" />
<q-tab name="announcements" icon="campaign" label="ประกาศ" />
</q-tabs>
@ -135,6 +136,11 @@
<CourseQuizResultsTab :course-id="course.id" :chapters="course.chapters" />
</q-tab-panel>
<!-- Approval History Tab -->
<q-tab-panel name="history" class="p-6">
<CourseApprovalHistoryTab :course-id="course.id" />
</q-tab-panel>
<!-- Announcements Tab -->
<q-tab-panel name="announcements" class="p-6">
<CourseAnnouncementsTab :course-id="course.id" />

View file

@ -589,9 +589,40 @@ export const instructorService = {
`/api/instructors/courses/${courseId}/announcements/${announcementId}/attachments/${attachmentId}`,
{ method: 'DELETE' }
);
},
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
const response = await authRequest<{
code: number;
message: string;
data: {
approval_history: ApprovalHistory[];
current_status: string;
course_title: { en: string; th: string };
course_id: number;
}
}>(`/api/instructors/courses/${courseId}/approval-history`);
return response.data.approval_history;
}
};
// Approval History Interface
export interface ApprovalHistory {
id: number;
action: string;
comment: string | null;
created_at: string;
submitter: {
id: number;
username: string;
email: string;
};
reviewer: {
id: number;
username: string;
email: string;
} | null;
}
// Create course request
export interface CreateCourseRequest {
category_id: number;
@ -678,6 +709,7 @@ export interface QuizResponse {
shuffle_choices: boolean;
show_answers_after_completion: boolean;
is_skippable: boolean;
allow_multiple_attempts: boolean;
created_at?: string;
updated_at?: string;
questions?: QuizQuestionResponse[];
@ -692,6 +724,7 @@ export interface UpdateQuizSettingsRequest {
shuffle_choices: boolean;
show_answers_after_completion: boolean;
is_skippable: boolean;
allow_multiple_attempts: boolean;
}
export interface CreateChapterRequest {