feat: Implement admin user and pending course management, instructor course listing, and a dedicated admin service.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 42s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s

This commit is contained in:
Missez 2026-02-24 14:43:06 +07:00
parent 5ad7184e6c
commit 9dc8636d31
4 changed files with 212 additions and 46 deletions

View file

@ -31,33 +31,48 @@
</div> </div>
</div> </div>
<!-- Search --> <!-- Search & View Toggle -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<q-input <div class="flex gap-4 items-center">
v-model="searchQuery" <div class="flex-1">
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..." <q-input
outlined v-model="searchQuery"
dense placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
bg-color="grey-1" outlined
> dense
<template v-slot:prepend> bg-color="grey-1"
<q-icon name="search" /> >
</template> <template v-slot:prepend>
<template v-slot:append v-if="searchQuery"> <q-icon name="search" />
<q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" /> </template>
</template> <template v-slot:append v-if="searchQuery">
</q-input> <q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" />
</div> </template>
</q-input>
</div>
<div class="flex justify-end mb-6"> <q-btn-toggle
<q-btn-toggle
v-model="viewMode" v-model="viewMode"
toggle-color="primary" toggle-color="primary"
:options="[ :options="[
{ label: 'การ์ด', value: 'card' }, { value: 'card', slot: 'card' },
{ label: 'ตาราง', value: 'table' } { value: 'table', slot: 'table' }
]" ]"
/> dense
rounded
unelevated
class="border"
>
<template v-slot:card>
<q-icon name="view_stream" size="20px" />
<q-tooltip>มมองการ</q-tooltip>
</template>
<template v-slot:table>
<q-icon name="view_list" size="20px" />
<q-tooltip>มมองตาราง</q-tooltip>
</template>
</q-btn-toggle>
</div>
</div> </div>
<!-- Pending Courses List --> <!-- Pending Courses List -->

View file

@ -216,7 +216,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { adminService, type AdminUserResponse } from '~/services/admin.service'; import { adminService, type AdminUserResponse, type RoleResponse } from '~/services/admin.service';
import { useAuthStore } from '~/stores/auth'; import { useAuthStore } from '~/stores/auth';
definePageMeta({ definePageMeta({
@ -228,6 +228,7 @@ const $q = useQuasar();
// Data // Data
const users = ref<AdminUserResponse[]>([]); const users = ref<AdminUserResponse[]>([]);
const roles = ref<RoleResponse[]>([]);
const loading = ref(true); const loading = ref(true);
const searchQuery = ref(''); const searchQuery = ref('');
const filterRole = ref<string | null>(null); const filterRole = ref<string | null>(null);
@ -286,6 +287,14 @@ const filteredUsers = computed(() => {
}); });
// Methods // Methods
const fetchRoles = async () => {
try {
roles.value = await adminService.getRoles();
} catch (error) {
console.error('Failed to fetch roles:', error);
}
};
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true; loading.value = true;
try { try {
@ -328,24 +337,32 @@ const viewUser = (user: AdminUserResponse) => {
showViewModal.value = true; showViewModal.value = true;
}; };
const changeRole = (user: AdminUserResponse) => { const getRoleLabel = (code: string): string => {
const roleIds: Record<string, number> = { const labels: Record<string, string> = {
INSTRUCTOR: 1, INSTRUCTOR: 'Instructor',
STUDENT: 2, STUDENT: 'Student',
ADMIN: 3 ADMIN: 'Admin'
}; };
return labels[code] || code;
};
const changeRole = (user: AdminUserResponse) => {
// Find current role ID from fetched roles
const currentRole = roles.value.find(r => r.code === user.role.code);
// Build items from API roles
const roleItems = roles.value.map(r => ({
label: getRoleLabel(r.code),
value: r.id
}));
$q.dialog({ $q.dialog({
title: 'เปลี่ยน Role', title: 'เปลี่ยน Role',
message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`, message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`,
options: { options: {
type: 'radio', type: 'radio',
model: roleIds[user.role.code] as any, model: (currentRole?.id ?? 0) as any,
items: [ items: roleItems
{ label: 'Instructor', value: 1 },
{ label: 'Student', value: 2 },
{ label: 'Admin', value: 3 }
]
}, },
cancel: true, cancel: true,
persistent: true persistent: true
@ -415,6 +432,7 @@ const exportExcel = () => {
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
fetchRoles();
fetchUsers(); fetchUsers();
}); });
</script> </script>

View file

@ -39,8 +39,8 @@
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="flex gap-4 items-center">
<div class="md:col-span-2"> <div class="flex-1">
<q-input <q-input
v-model="searchQuery" v-model="searchQuery"
placeholder="ค้นหาหลักสูตร..." placeholder="ค้นหาหลักสูตร..."
@ -62,15 +62,39 @@
dense dense
emit-value emit-value
map-options map-options
style="min-width: 160px"
/> />
<q-btn-toggle
v-model="viewMode"
toggle-color="primary"
:options="[
{ value: 'card', slot: 'card' },
{ value: 'table', slot: 'table' }
]"
dense
rounded
unelevated
class="border"
>
<template v-slot:card>
<q-icon name="grid_view" size="20px" />
<q-tooltip>มมองการ</q-tooltip>
</template>
<template v-slot:table>
<q-icon name="view_list" size="20px" />
<q-tooltip>มมองตาราง</q-tooltip>
</template>
</q-btn-toggle>
</div> </div>
</div> </div>
<!-- Courses Grid --> <!-- Loading -->
<div v-if="loading" class="flex justify-center py-10"> <div v-if="loading" class="flex justify-center py-10">
<q-spinner-dots size="50px" color="primary" /> <q-spinner-dots size="50px" color="primary" />
</div> </div>
<!-- Empty State -->
<div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center"> <div v-else-if="filteredCourses.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center">
<q-icon name="school" size="60px" color="grey-5" class="mb-4" /> <q-icon name="school" size="60px" color="grey-5" class="mb-4" />
<p class="text-gray-500 text-lg">งไมหลกสตร</p> <p class="text-gray-500 text-lg">งไมหลกสตร</p>
@ -82,7 +106,8 @@
/> />
</div> </div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <!-- Card View -->
<div v-else-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div <div
v-for="course in filteredCourses" v-for="course in filteredCourses"
:key="course.id" :key="course.id"
@ -134,15 +159,6 @@
> >
<q-tooltip>รายละเอยด</q-tooltip> <q-tooltip>รายละเอยด</q-tooltip>
</q-btn> </q-btn>
<!-- <q-btn
flat
dense
icon="edit"
color="primary"
@click="navigateTo(`/instructor/courses/${course.id}/edit`)"
>
<q-tooltip>แกไข</q-tooltip>
</q-btn> -->
<q-space /> <q-space />
<q-btn flat round dense icon="more_vert"> <q-btn flat round dense icon="more_vert">
<q-menu> <q-menu>
@ -167,6 +183,94 @@
</div> </div>
</div> </div>
<!-- Table View -->
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden">
<q-table
:rows="filteredCourses"
:columns="tableColumns"
row-key="id"
flat
:pagination="tablePagination"
:rows-per-page-options="[10, 20, 50, 0]"
@update:pagination="tablePagination = $event"
>
<!-- Thumbnail + Title -->
<template v-slot:body-cell-title="props">
<q-td :props="props">
<div class="flex items-center gap-3">
<div class="w-16 h-10 rounded overflow-hidden flex-shrink-0 bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
<img
v-if="props.row.thumbnail_url"
:src="props.row.thumbnail_url"
:alt="props.row.title.th"
class="w-full h-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<q-icon v-else name="school" size="20px" color="white" />
</div>
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ props.row.title.th }}</div>
<div class="text-xs text-gray-400 truncate">{{ props.row.title.en }}</div>
</div>
</div>
</q-td>
</template>
<!-- Status Badge -->
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="getStatusColor(props.row.status)">
{{ getStatusLabel(props.row.status) }}
</q-badge>
</q-td>
</template>
<!-- Price -->
<template v-slot:body-cell-price="props">
<q-td :props="props">
<span class="font-medium" :class="props.row.is_free ? 'text-green-600' : 'text-primary-600'">
{{ props.row.is_free ? 'ฟรี' : `฿${parseFloat(props.row.price).toLocaleString()}` }}
</span>
</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="visibility" color="grey" size="sm" @click="handleViewDetails(props.row)">
<q-tooltip>รายละเอยด</q-tooltip>
</q-btn>
<q-btn flat round dense icon="more_vert" size="sm">
<q-menu>
<q-list style="min-width: 150px">
<q-item clickable v-close-popup @click="duplicateCourse(props.row)">
<q-item-section avatar>
<q-icon name="content_copy" />
</q-item-section>
<q-item-section>ทำสำเนา</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>
<!-- Rejection Details Dialog --> <!-- Rejection Details Dialog -->
<q-dialog v-model="rejectionDialog"> <q-dialog v-model="rejectionDialog">
<q-card style="min-width: 400px"> <q-card style="min-width: 400px">
@ -256,6 +360,17 @@ const courses = ref<CourseResponse[]>([]);
const loading = ref(true); const loading = ref(true);
const searchQuery = ref(''); const searchQuery = ref('');
const filterStatus = ref<string | null>(null); const filterStatus = ref<string | null>(null);
const viewMode = ref<'card' | 'table'>('card');
// Table config
const tablePagination = ref({ page: 1, rowsPerPage: 10 });
const tableColumns = [
{ name: 'title', label: 'หลักสูตร', field: 'title', align: 'left' as const, sortable: true },
{ name: 'status', label: 'สถานะ', field: 'status', align: 'center' as const, sortable: true },
{ name: 'price', label: 'ราคา', field: 'price', align: 'center' as const, sortable: true },
{ name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const, sortable: true },
{ name: 'actions', label: 'จัดการ', field: 'actions', align: 'center' as const }
];
// Status options // Status options
const statusOptions = [ const statusOptions = [

View file

@ -320,7 +320,25 @@ const getAuthToken = (): string => {
return tokenCookie.value || ''; return tokenCookie.value || '';
}; };
// Role interface
export interface RoleResponse {
id: number;
code: string;
}
export const adminService = { export const adminService = {
async getRoles(): Promise<RoleResponse[]> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<{ roles: RoleResponse[] }>('/api/user/roles', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.roles;
},
async getUsers(): Promise<AdminUserResponse[]> { async getUsers(): Promise<AdminUserResponse[]> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const token = getAuthToken(); const token = getAuthToken();