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>
|
||||
|
||||
<!-- Search -->
|
||||
<!-- Search & View Toggle -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<q-input
|
||||
v-model="searchQuery"
|
||||
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
|
||||
outlined
|
||||
dense
|
||||
bg-color="grey-1"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
<template v-slot:append v-if="searchQuery">
|
||||
<q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex-1">
|
||||
<q-input
|
||||
v-model="searchQuery"
|
||||
placeholder="ค้นหาชื่อคอร์ส, ผู้สอน..."
|
||||
outlined
|
||||
dense
|
||||
bg-color="grey-1"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
<template v-slot:append v-if="searchQuery">
|
||||
<q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-6">
|
||||
<q-btn-toggle
|
||||
<q-btn-toggle
|
||||
v-model="viewMode"
|
||||
toggle-color="primary"
|
||||
:options="[
|
||||
{ label: 'การ์ด', value: 'card' },
|
||||
{ label: 'ตาราง', value: 'table' }
|
||||
{ value: 'card', slot: 'card' },
|
||||
{ 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>
|
||||
|
||||
<!-- Pending Courses List -->
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
|
||||
definePageMeta({
|
||||
|
|
@ -228,6 +228,7 @@ const $q = useQuasar();
|
|||
|
||||
// Data
|
||||
const users = ref<AdminUserResponse[]>([]);
|
||||
const roles = ref<RoleResponse[]>([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const filterRole = ref<string | null>(null);
|
||||
|
|
@ -286,6 +287,14 @@ const filteredUsers = computed(() => {
|
|||
});
|
||||
|
||||
// Methods
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
roles.value = await adminService.getRoles();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch roles:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
|
@ -328,24 +337,32 @@ const viewUser = (user: AdminUserResponse) => {
|
|||
showViewModal.value = true;
|
||||
};
|
||||
|
||||
const changeRole = (user: AdminUserResponse) => {
|
||||
const roleIds: Record<string, number> = {
|
||||
INSTRUCTOR: 1,
|
||||
STUDENT: 2,
|
||||
ADMIN: 3
|
||||
const getRoleLabel = (code: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
INSTRUCTOR: 'Instructor',
|
||||
STUDENT: 'Student',
|
||||
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({
|
||||
title: 'เปลี่ยน Role',
|
||||
message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`,
|
||||
options: {
|
||||
type: 'radio',
|
||||
model: roleIds[user.role.code] as any,
|
||||
items: [
|
||||
{ label: 'Instructor', value: 1 },
|
||||
{ label: 'Student', value: 2 },
|
||||
{ label: 'Admin', value: 3 }
|
||||
]
|
||||
model: (currentRole?.id ?? 0) as any,
|
||||
items: roleItems
|
||||
},
|
||||
cancel: true,
|
||||
persistent: true
|
||||
|
|
@ -415,6 +432,7 @@ const exportExcel = () => {
|
|||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchRoles();
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@
|
|||
|
||||
<!-- Filter Bar -->
|
||||
<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="md:col-span-2">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex-1">
|
||||
<q-input
|
||||
v-model="searchQuery"
|
||||
placeholder="ค้นหาหลักสูตร..."
|
||||
|
|
@ -62,15 +62,39 @@
|
|||
dense
|
||||
emit-value
|
||||
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>
|
||||
|
||||
<!-- Courses Grid -->
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<q-spinner-dots size="50px" color="primary" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<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" />
|
||||
<p class="text-gray-500 text-lg">ยังไม่มีหลักสูตร</p>
|
||||
|
|
@ -82,7 +106,8 @@
|
|||
/>
|
||||
</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
|
||||
v-for="course in filteredCourses"
|
||||
:key="course.id"
|
||||
|
|
@ -134,15 +159,6 @@
|
|||
>
|
||||
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
||||
</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-btn flat round dense icon="more_vert">
|
||||
<q-menu>
|
||||
|
|
@ -167,6 +183,94 @@
|
|||
</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 -->
|
||||
<q-dialog v-model="rejectionDialog">
|
||||
<q-card style="min-width: 400px">
|
||||
|
|
@ -256,6 +360,17 @@ const courses = ref<CourseResponse[]>([]);
|
|||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
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
|
||||
const statusOptions = [
|
||||
|
|
|
|||
|
|
@ -320,7 +320,25 @@ const getAuthToken = (): string => {
|
|||
return tokenCookie.value || '';
|
||||
};
|
||||
|
||||
// Role interface
|
||||
export interface RoleResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getAuthToken();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue