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
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:
parent
5ad7184e6c
commit
9dc8636d31
4 changed files with 212 additions and 46 deletions
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue