feat: Implement user authentication, admin user management, and role-based access control.
This commit is contained in:
parent
8a2ca592bc
commit
38648581ec
19 changed files with 1762 additions and 514 deletions
|
|
@ -1,59 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-card
|
|
||||||
:class="[
|
|
||||||
'rounded-lg shadow-md transition-shadow',
|
|
||||||
hoverable && 'hover:shadow-lg cursor-pointer',
|
|
||||||
customClass
|
|
||||||
]"
|
|
||||||
style="border: 1px solid #e5e7eb;"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<!-- Card Header -->
|
|
||||||
<q-card-section v-if="$slots.header || title" class="border-b border-gray-200">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h3 v-if="title" class="text-lg font-semibold text-gray-900">{{ title }}</h3>
|
|
||||||
<p v-if="subtitle" class="text-sm text-gray-600 mt-1">{{ subtitle }}</p>
|
|
||||||
</div>
|
|
||||||
<slot name="header-actions"></slot>
|
|
||||||
</div>
|
|
||||||
<slot name="header"></slot>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Card Content -->
|
|
||||||
<q-card-section :class="contentClass">
|
|
||||||
<slot></slot>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Card Footer -->
|
|
||||||
<q-card-section v-if="$slots.footer" class="border-t border-gray-200">
|
|
||||||
<slot name="footer"></slot>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
hoverable?: boolean;
|
|
||||||
customClass?: string;
|
|
||||||
contentClass?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
title: '',
|
|
||||||
subtitle: '',
|
|
||||||
hoverable: false,
|
|
||||||
customClass: '',
|
|
||||||
contentClass: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
click: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
emit('click');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<template>
|
|
||||||
<header class="bg-white shadow-sm border-b border-gray-200 px-8 py-4">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<!-- Left: Page Title -->
|
|
||||||
<div>
|
|
||||||
<h1 v-if="title" class="text-2xl font-bold text-gray-900">{{ title }}</h1>
|
|
||||||
<p v-if="subtitle" class="text-sm text-gray-600 mt-1">{{ subtitle }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Actions & User -->
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<!-- Action Buttons Slot -->
|
|
||||||
<slot name="actions"></slot>
|
|
||||||
|
|
||||||
<!-- User Avatar & Info -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="text-right">
|
|
||||||
<div class="text-sm font-semibold text-gray-900">{{ userName }}</div>
|
|
||||||
<div class="text-xs text-gray-500">{{ userRole }}</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-lg cursor-pointer hover:bg-primary-200 transition"
|
|
||||||
@click="$emit('avatar-click')"
|
|
||||||
>
|
|
||||||
{{ userAvatar }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
userName?: string;
|
|
||||||
userRole?: string;
|
|
||||||
userAvatar?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
title: '',
|
|
||||||
subtitle: '',
|
|
||||||
userName: 'ผู้ใช้งาน',
|
|
||||||
userRole: 'User',
|
|
||||||
userAvatar: '👤'
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'avatar-click': [];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-dialog
|
|
||||||
:model-value="modelValue"
|
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
|
||||||
:persistent="persistent"
|
|
||||||
>
|
|
||||||
<q-card :style="{ width: width, maxWidth: maxWidth }">
|
|
||||||
<!-- Modal Header -->
|
|
||||||
<q-card-section class="row items-center q-pb-none">
|
|
||||||
<div class="text-h6">{{ title }}</div>
|
|
||||||
<q-space />
|
|
||||||
<q-btn
|
|
||||||
v-if="showClose"
|
|
||||||
icon="close"
|
|
||||||
flat
|
|
||||||
round
|
|
||||||
dense
|
|
||||||
@click="handleClose"
|
|
||||||
/>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Modal Content -->
|
|
||||||
<q-card-section :class="contentClass">
|
|
||||||
<slot></slot>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<!-- Modal Actions -->
|
|
||||||
<q-card-actions v-if="$slots.actions || showDefaultActions" align="right" class="q-px-md q-pb-md">
|
|
||||||
<slot name="actions">
|
|
||||||
<q-btn
|
|
||||||
v-if="showDefaultActions"
|
|
||||||
flat
|
|
||||||
:label="cancelLabel"
|
|
||||||
color="grey"
|
|
||||||
@click="handleCancel"
|
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
v-if="showDefaultActions"
|
|
||||||
unelevated
|
|
||||||
:label="confirmLabel"
|
|
||||||
:color="confirmColor"
|
|
||||||
:loading="loading"
|
|
||||||
@click="handleConfirm"
|
|
||||||
/>
|
|
||||||
</slot>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
modelValue: boolean;
|
|
||||||
title?: string;
|
|
||||||
width?: string;
|
|
||||||
maxWidth?: string;
|
|
||||||
persistent?: boolean;
|
|
||||||
showClose?: boolean;
|
|
||||||
showDefaultActions?: boolean;
|
|
||||||
cancelLabel?: string;
|
|
||||||
confirmLabel?: string;
|
|
||||||
confirmColor?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
contentClass?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
title: '',
|
|
||||||
width: '500px',
|
|
||||||
maxWidth: '90vw',
|
|
||||||
persistent: false,
|
|
||||||
showClose: true,
|
|
||||||
showDefaultActions: true,
|
|
||||||
cancelLabel: 'ยกเลิก',
|
|
||||||
confirmLabel: 'ยืนยัน',
|
|
||||||
confirmColor: 'primary',
|
|
||||||
loading: false,
|
|
||||||
contentClass: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean];
|
|
||||||
'confirm': [];
|
|
||||||
'cancel': [];
|
|
||||||
'close': [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
emit('update:modelValue', false);
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('update:modelValue', false);
|
|
||||||
emit('cancel');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
emit('confirm');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full border-collapse">
|
|
||||||
<!-- Table Header -->
|
|
||||||
<thead class="bg-gray-50 border-b border-gray-200">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
v-for="column in columns"
|
|
||||||
:key="column.key"
|
|
||||||
:class="[
|
|
||||||
'px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider',
|
|
||||||
column.align === 'center' && 'text-center',
|
|
||||||
column.align === 'right' && 'text-right',
|
|
||||||
column.headerClass
|
|
||||||
]"
|
|
||||||
:style="{ width: column.width }"
|
|
||||||
>
|
|
||||||
{{ column.label }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<!-- Table Body -->
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr
|
|
||||||
v-for="(row, index) in data"
|
|
||||||
:key="getRowKey(row, index)"
|
|
||||||
:class="[
|
|
||||||
'hover:bg-gray-50 transition',
|
|
||||||
rowClickable && 'cursor-pointer'
|
|
||||||
]"
|
|
||||||
@click="handleRowClick(row)"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
v-for="column in columns"
|
|
||||||
:key="column.key"
|
|
||||||
:class="[
|
|
||||||
'px-6 py-4 text-sm text-gray-900',
|
|
||||||
column.align === 'center' && 'text-center',
|
|
||||||
column.align === 'right' && 'text-right',
|
|
||||||
column.cellClass
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<!-- Custom Cell Slot -->
|
|
||||||
<slot
|
|
||||||
v-if="$slots[`cell-${column.key}`]"
|
|
||||||
:name="`cell-${column.key}`"
|
|
||||||
:row="row"
|
|
||||||
:value="row[column.key]"
|
|
||||||
></slot>
|
|
||||||
|
|
||||||
<!-- Default Cell Content -->
|
|
||||||
<span v-else>{{ row[column.key] }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<tr v-if="data.length === 0">
|
|
||||||
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500">
|
|
||||||
<slot name="empty">
|
|
||||||
<div class="text-gray-400">
|
|
||||||
<p class="text-lg mb-2">📭</p>
|
|
||||||
<p>{{ emptyMessage }}</p>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div v-if="showPagination && totalPages > 1" class="flex justify-between items-center px-6 py-4 border-t border-gray-200">
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
แสดง {{ startItem }}-{{ endItem }} จาก {{ totalItems }} รายการ
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
icon="chevron_left"
|
|
||||||
:disable="currentPage === 1"
|
|
||||||
@click="changePage(currentPage - 1)"
|
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
v-for="page in visiblePages"
|
|
||||||
:key="page"
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
:label="String(page)"
|
|
||||||
:color="page === currentPage ? 'primary' : 'grey'"
|
|
||||||
@click="changePage(page)"
|
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
icon="chevron_right"
|
|
||||||
:disable="currentPage === totalPages"
|
|
||||||
@click="changePage(currentPage + 1)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Column {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
width?: string;
|
|
||||||
align?: 'left' | 'center' | 'right';
|
|
||||||
headerClass?: string;
|
|
||||||
cellClass?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
columns: Column[];
|
|
||||||
data: any[];
|
|
||||||
rowKey?: string;
|
|
||||||
rowClickable?: boolean;
|
|
||||||
emptyMessage?: string;
|
|
||||||
showPagination?: boolean;
|
|
||||||
currentPage?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
totalItems?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
rowKey: 'id',
|
|
||||||
rowClickable: false,
|
|
||||||
emptyMessage: 'ไม่พบข้อมูล',
|
|
||||||
showPagination: false,
|
|
||||||
currentPage: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
totalItems: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'row-click': [row: any];
|
|
||||||
'page-change': [page: number];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const getRowKey = (row: any, index: number) => {
|
|
||||||
return row[props.rowKey] || index;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowClick = (row: any) => {
|
|
||||||
if (props.rowClickable) {
|
|
||||||
emit('row-click', row);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const changePage = (page: number) => {
|
|
||||||
emit('page-change', page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pagination calculations
|
|
||||||
const totalPages = computed(() => Math.ceil(props.totalItems / props.pageSize));
|
|
||||||
const startItem = computed(() => (props.currentPage - 1) * props.pageSize + 1);
|
|
||||||
const endItem = computed(() => Math.min(props.currentPage * props.pageSize, props.totalItems));
|
|
||||||
|
|
||||||
const visiblePages = computed(() => {
|
|
||||||
const pages = [];
|
|
||||||
const maxVisible = 5;
|
|
||||||
let start = Math.max(1, props.currentPage - Math.floor(maxVisible / 2));
|
|
||||||
let end = Math.min(totalPages.value, start + maxVisible - 1);
|
|
||||||
|
|
||||||
if (end - start < maxVisible - 1) {
|
|
||||||
start = Math.max(1, end - maxVisible + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
pages.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import type { UseFetchOptions } from 'nuxt/app';
|
|
||||||
|
|
||||||
export const useApi = () => {
|
|
||||||
const config = useRuntimeConfig();
|
|
||||||
const baseURL = config.public.apiBaseUrl as string;
|
|
||||||
|
|
||||||
const apiFetch = <T>(url: string, options?: UseFetchOptions<T>) => {
|
|
||||||
return $fetch<T>(url, {
|
|
||||||
baseURL,
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
onRequest({ options }) {
|
|
||||||
// Add auth token if available
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
options.headers = {
|
|
||||||
...options.headers,
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onResponseError({ response }) {
|
|
||||||
// Handle errors globally
|
|
||||||
console.error('API Error:', response.status, response._data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
apiFetch
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
export default defineNuxtRouteMiddleware((to, from) => {
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||||||
|
// Skip on server side - let client handle auth
|
||||||
|
if (process.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
authStore.checkAuth();
|
|
||||||
|
// Restore session if not authenticated
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
authStore.checkAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
if (!authStore.isAdmin) {
|
if (!authStore.isAdmin) {
|
||||||
return navigateTo('/login');
|
return navigateTo('/login');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
export default defineNuxtRouteMiddleware((to, from) => {
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||||||
|
// Skip on server side - let client handle auth
|
||||||
|
if (process.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip middleware on login page
|
||||||
|
if (to.path === '/login') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
authStore.checkAuth();
|
|
||||||
|
// Restore session if not authenticated
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
authStore.checkAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication after restore
|
||||||
if (!authStore.isAuthenticated) {
|
if (!authStore.isAuthenticated) {
|
||||||
return navigateTo('/login');
|
return navigateTo('/login');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
328
frontend_management/pages/admin/categories/index.vue
Normal file
328
frontend_management/pages/admin/categories/index.vue
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-primary-600">จัดการหมวดหมู่</h1>
|
||||||
|
<p class="text-gray-600 mt-1">จัดการหมวดหมู่หลักสูตรในระบบ</p>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="+ เพิ่มหมวดหมู่ใหม่"
|
||||||
|
@click="openAddModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Card -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-primary-600 mb-4">รายการหมวดหมู่</h2>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="mb-4" style="max-width: 400px">
|
||||||
|
<q-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="ค้นหาหมวดหมู่..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
bg-color="grey-1"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<q-table
|
||||||
|
:rows="filteredCategories"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<!-- Name -->
|
||||||
|
<template v-slot:body-cell-name="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="font-medium">{{ props.row.name.th }}</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<template v-slot:body-cell-description="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="text-gray-600">{{ props.row.description.th }}</div>
|
||||||
|
</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">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="edit"
|
||||||
|
color="warning"
|
||||||
|
@click="openEditModal(props.row)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
@click="confirmDelete(props.row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<q-dialog v-model="showModal" persistent>
|
||||||
|
<q-card style="min-width: 500px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">{{ isEditing ? 'แก้ไขหมวดหมู่' : 'เพิ่มหมวดหมู่ใหม่' }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="showModal = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-form @submit="handleSave">
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<q-input
|
||||||
|
v-model="form.name.th"
|
||||||
|
label="ชื่อ (ภาษาไทย)"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="form.name.en"
|
||||||
|
label="ชื่อ (English)"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'Please enter name']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="form.slug"
|
||||||
|
label="Slug (URL)"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
hint="ใช้สำหรับ URL เช่น web-development"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอก slug']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="form.description.th"
|
||||||
|
label="คำอธิบาย (ภาษาไทย)"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
autogrow
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="form.description.en"
|
||||||
|
label="คำอธิบาย (English)"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
class="mb-4"
|
||||||
|
autogrow
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="ยกเลิก"
|
||||||
|
color="grey-7"
|
||||||
|
@click="showModal = false"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
:label="isEditing ? 'บันทึก' : 'เพิ่ม'"
|
||||||
|
color="primary"
|
||||||
|
:loading="saving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { adminService, type CategoryResponse } from '~/services/admin.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['auth', 'admin']
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const categories = ref<CategoryResponse[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const showModal = ref(false);
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const editingId = ref<number | null>(null);
|
||||||
|
|
||||||
|
// Form
|
||||||
|
const form = ref({
|
||||||
|
name: { th: '', en: '' },
|
||||||
|
slug: '',
|
||||||
|
description: { th: '', en: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table columns
|
||||||
|
const columns = [
|
||||||
|
{ name: 'id', label: 'ID', field: 'id', align: 'center' as const, style: 'width: 60px' },
|
||||||
|
{ name: 'name', label: 'ชื่อหมวดหมู่', field: 'name', align: 'left' as const },
|
||||||
|
{ name: 'description', label: 'คำอธิบาย', field: 'description', align: 'left' as const },
|
||||||
|
{ name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const },
|
||||||
|
{ name: 'actions', label: 'การจัดการ', field: 'actions', align: 'center' as const, style: 'width: 120px' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredCategories = computed(() => {
|
||||||
|
if (!searchQuery.value) return categories.value;
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
return categories.value.filter(cat =>
|
||||||
|
cat.name.th.toLowerCase().includes(query) ||
|
||||||
|
cat.name.en.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
categories.value = await adminService.getCategories();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลหมวดหมู่ได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = {
|
||||||
|
name: { th: '', en: '' },
|
||||||
|
slug: '',
|
||||||
|
description: { th: '', en: '' }
|
||||||
|
};
|
||||||
|
editingId.value = null;
|
||||||
|
isEditing.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
resetForm();
|
||||||
|
showModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (category: CategoryResponse) => {
|
||||||
|
isEditing.value = true;
|
||||||
|
editingId.value = category.id;
|
||||||
|
form.value = {
|
||||||
|
name: { ...category.name },
|
||||||
|
slug: category.slug,
|
||||||
|
description: { ...category.description }
|
||||||
|
};
|
||||||
|
showModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEditing.value && editingId.value) {
|
||||||
|
await adminService.updateCategory(editingId.value, {
|
||||||
|
id: editingId.value,
|
||||||
|
...form.value
|
||||||
|
});
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'แก้ไขหมวดหมู่สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await adminService.createCategory({
|
||||||
|
...form.value,
|
||||||
|
created_by: parseInt(authStore.user?.id || '0')
|
||||||
|
});
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'เพิ่มหมวดหมู่สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showModal.value = false;
|
||||||
|
fetchCategories();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (category: CategoryResponse) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ยืนยันการลบ',
|
||||||
|
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await adminService.deleteCategory(category.id);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ลบหมวดหมู่สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
fetchCategories();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการลบ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCategories();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
394
frontend_management/pages/admin/users/index.vue
Normal file
394
frontend_management/pages/admin/users/index.vue
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-primary-600">จัดการผู้ใช้งาน</h1>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="red"
|
||||||
|
label="ส่งออก Excel"
|
||||||
|
icon="download"
|
||||||
|
@click="exportExcel"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="+ เพิ่มผู้ใช้ใหม่"
|
||||||
|
@click="showAddModal = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and Filter -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<q-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="ค้นหาชื่อ, อีเมล, username..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
bg-color="grey-1"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-model="filterRole"
|
||||||
|
:options="roleOptions"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
|
||||||
|
<div class="text-4xl font-bold text-primary-600">{{ stats.total.toLocaleString() }}</div>
|
||||||
|
<div class="text-gray-500 mt-1">ผู้ใช้ทั้งหมด</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
|
||||||
|
<div class="text-4xl font-bold text-gray-700">{{ stats.admin }}</div>
|
||||||
|
<div class="text-gray-500 mt-1">Admin</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
|
||||||
|
<div class="text-4xl font-bold text-gray-700">{{ stats.instructor }}</div>
|
||||||
|
<div class="text-gray-500 mt-1">Instructor</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
|
||||||
|
<div class="text-4xl font-bold text-gray-700">{{ stats.student.toLocaleString() }}</div>
|
||||||
|
<div class="text-gray-500 mt-1">Student</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<q-table
|
||||||
|
:rows="filteredUsers"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
flat
|
||||||
|
:pagination="pagination"
|
||||||
|
@update:pagination="pagination = $event"
|
||||||
|
>
|
||||||
|
<!-- User Info -->
|
||||||
|
<template v-slot:body-cell-user="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||||
|
<q-icon name="person" color="primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-primary-600 hover:underline cursor-pointer">
|
||||||
|
{{ getFullName(props.row) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ props.row.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Role Badge -->
|
||||||
|
<template v-slot:body-cell-role="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-badge
|
||||||
|
:color="getRoleBadgeColor(props.row.role.code)"
|
||||||
|
:label="props.row.role.name.th"
|
||||||
|
class="px-3 py-1"
|
||||||
|
/>
|
||||||
|
</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="more_vert">
|
||||||
|
<q-menu>
|
||||||
|
<q-list style="min-width: 150px">
|
||||||
|
<q-item clickable v-close-popup @click="viewUser(props.row)">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="visibility" color="grey" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>ดู</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable v-close-popup @click="changeRole(props.row)">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="swap_horiz" color="primary" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>แก้ Role</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>
|
||||||
|
|
||||||
|
<!-- View User Modal -->
|
||||||
|
<q-dialog v-model="showViewModal">
|
||||||
|
<q-card style="min-width: 500px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">รายละเอียดผู้ใช้</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="showViewModal = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="selectedUser">
|
||||||
|
<!-- Avatar & Name -->
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center text-3xl">
|
||||||
|
<q-icon name="person" color="primary" size="40px" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-bold text-gray-900">
|
||||||
|
{{ selectedUser.profile.prefix?.th }}{{ selectedUser.profile.first_name }} {{ selectedUser.profile.last_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500">@{{ selectedUser.username }}</div>
|
||||||
|
<q-badge :color="getRoleBadgeColor(selectedUser.role.code)" class="mt-1">
|
||||||
|
{{ selectedUser.role.name.th }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500">อีเมล</div>
|
||||||
|
<div class="font-medium">{{ selectedUser.email }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500">เบอร์โทร</div>
|
||||||
|
<div class="font-medium">{{ selectedUser.profile.phone || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500">วันที่ลงทะเบียน</div>
|
||||||
|
<div class="font-medium">{{ formatDate(selectedUser.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-500">อัพเดทล่าสุด</div>
|
||||||
|
<div class="font-medium">{{ formatDate(selectedUser.updated_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="ปิด" color="primary" @click="showViewModal = false" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { adminService, type AdminUserResponse } from '~/services/admin.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['auth', 'admin']
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const users = ref<AdminUserResponse[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const filterRole = ref<string | null>(null);
|
||||||
|
const showAddModal = ref(false);
|
||||||
|
const showViewModal = ref(false);
|
||||||
|
const selectedUser = ref<AdminUserResponse | null>(null);
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
rowsPerPage: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table columns
|
||||||
|
const columns = [
|
||||||
|
{ name: 'user', label: 'ผู้ใช้งาน', field: 'user', align: 'left' as const },
|
||||||
|
{ name: 'role', label: 'บทบาท', field: 'role', align: 'center' as const },
|
||||||
|
{ name: 'created_at', label: 'ลงทะเบียน', field: 'created_at', align: 'center' as const },
|
||||||
|
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter options
|
||||||
|
const roleOptions = [
|
||||||
|
{ label: 'บทบาททั้งหมด', value: null },
|
||||||
|
{ label: 'Instructor', value: 'INSTRUCTOR' },
|
||||||
|
{ label: 'Student', value: 'STUDENT' },
|
||||||
|
{ label: 'Admin', value: 'ADMIN' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Stats computed
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = users.value.length;
|
||||||
|
const admin = users.value.filter(u => u.role.code === 'ADMIN').length;
|
||||||
|
const instructor = users.value.filter(u => u.role.code === 'INSTRUCTOR').length;
|
||||||
|
const student = users.value.filter(u => u.role.code === 'STUDENT').length;
|
||||||
|
return { total, admin, instructor, student };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered users
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
let result = users.value;
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
result = result.filter(user =>
|
||||||
|
user.profile.first_name.toLowerCase().includes(query) ||
|
||||||
|
user.profile.last_name.toLowerCase().includes(query) ||
|
||||||
|
user.email.toLowerCase().includes(query) ||
|
||||||
|
user.username.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterRole.value) {
|
||||||
|
result = result.filter(user => user.role.code === filterRole.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
users.value = await adminService.getUsers();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลผู้ใช้ได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFullName = (user: AdminUserResponse) => {
|
||||||
|
const prefix = user.profile.prefix?.th || '';
|
||||||
|
return `${prefix}${user.profile.first_name} ${user.profile.last_name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (roleCode: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
ADMIN: 'red',
|
||||||
|
INSTRUCTOR: 'orange',
|
||||||
|
STUDENT: 'blue'
|
||||||
|
};
|
||||||
|
return colors[roleCode] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewUser = (user: AdminUserResponse) => {
|
||||||
|
selectedUser.value = user;
|
||||||
|
showViewModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRole = (user: AdminUserResponse) => {
|
||||||
|
const roleIds: Record<string, number> = {
|
||||||
|
INSTRUCTOR: 1,
|
||||||
|
STUDENT: 2,
|
||||||
|
ADMIN: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
$q.dialog({
|
||||||
|
title: 'เปลี่ยน Role',
|
||||||
|
message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`,
|
||||||
|
options: {
|
||||||
|
type: 'radio',
|
||||||
|
model: roleIds[user.role.code],
|
||||||
|
items: [
|
||||||
|
{ label: 'Instructor', value: 1 },
|
||||||
|
{ label: 'Student', value: 2 },
|
||||||
|
{ label: 'Admin', value: 3 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async (roleId: number) => {
|
||||||
|
try {
|
||||||
|
await adminService.updateUserRole(user.id, roleId);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'เปลี่ยน Role สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการเปลี่ยน Role',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (user: AdminUserResponse) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ยืนยันการลบ',
|
||||||
|
message: `คุณต้องการลบผู้ใช้ "${user.profile.first_name} ${user.profile.last_name}" หรือไม่?`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await adminService.deleteUser(user.id);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ลบผู้ใช้สำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการลบผู้ใช้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportExcel = () => {
|
||||||
|
$q.notify({
|
||||||
|
type: 'info',
|
||||||
|
message: 'กำลังส่งออกไฟล์ Excel...',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchUsers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">
|
<h1 class="text-2xl font-bold text-gray-900">
|
||||||
สวัสดี, {{ authStore.user?.fullName || 'อาจารย์' }}
|
สวัสดี, {{ authStore.user?.firstName }} {{ authStore.user?.lastName || 'อาจารย์' }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
<p class="text-gray-600 mt-2">ยินดีต้อนรับกลับสู่ระบบ</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.fullName || 'อาจารย์ทดสอบ' }}</div>
|
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.firstName }} {{ authStore.user?.lastName || 'อาจารย์ทดสอบ' }}</div>
|
||||||
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div>
|
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<!-- User Info Header -->
|
<!-- User Info Header -->
|
||||||
<q-item class="bg-primary-50">
|
<q-item class="bg-primary-50">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label class="text-weight-bold">{{ authStore.user?.fullName }}</q-item-label>
|
<q-item-label class="text-weight-bold">{{ authStore.user?.firstName }} {{ authStore.user?.lastName }}</q-item-label>
|
||||||
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
|
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Card -->
|
<!-- Profile Card -->
|
||||||
<AppCard class="mb-10 ">
|
<div class="mb-10 ">
|
||||||
<div class="flex flex-col md:flex-row gap-8">
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
<!-- Avatar Section -->
|
<!-- Avatar Section -->
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 mb-1">ตำแหน่ง</div>
|
<div class="text-sm text-gray-600 mb-1">ตำแหน่ง</div>
|
||||||
<div class="text-lg text-gray-900">
|
<div class="text-lg text-gray-900">
|
||||||
<q-badge color="primary">{{ getRoleLabel(profile.role) }}</q-badge>
|
<q-badge color="primary">{{ profile.roleName || getRoleLabel(profile.role) }}</q-badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards
|
<!-- Stats Cards
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5">
|
||||||
|
|
@ -108,11 +108,11 @@
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-form @submit="handleUpdateProfile">
|
<q-form @submit="handleUpdateProfile">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="editForm.fullName"
|
v-model="editForm.firstName"
|
||||||
label="ชื่อ-นามสกุล"
|
label="ชื่อ"
|
||||||
outlined
|
outlined
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
:rules="[val => !!val || 'กรุณากรอกชื่อ-นามสกุล']"
|
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="person" />
|
<q-icon name="person" />
|
||||||
|
|
@ -120,27 +120,14 @@
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="editForm.email"
|
v-model="editForm.lastName"
|
||||||
label="อีเมล"
|
label="นามสกุล"
|
||||||
type="email"
|
|
||||||
outlined
|
outlined
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
:rules="[val => !!val || 'กรุณากรอกนามสกุล']"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="email" />
|
<q-icon name="person" />
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<q-input
|
|
||||||
v-model="editForm.username"
|
|
||||||
label="Username"
|
|
||||||
outlined
|
|
||||||
class="mb-4"
|
|
||||||
:rules="[val => !!val || 'กรุณากรอก username']"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<q-icon name="account_circle" />
|
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
|
|
@ -274,6 +261,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
import { userService, type UserProfileResponse } from '~/services/user.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'instructor',
|
layout: 'instructor',
|
||||||
|
|
@ -283,15 +271,19 @@ definePageMeta({
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
// Profile data
|
// Profile data
|
||||||
const profile = ref({
|
const profile = ref({
|
||||||
fullName: 'อาจารย์ทดสอบ ระบบอีเลินนิ่ง',
|
fullName: '',
|
||||||
email: 'instructor@example.com',
|
email: '',
|
||||||
username: 'instructor_test',
|
username: '',
|
||||||
phone: '081-234-5678',
|
phone: '',
|
||||||
role: 'INSTRUCTOR',
|
role: '',
|
||||||
|
roleName: '',
|
||||||
avatar: '👨🏫',
|
avatar: '👨🏫',
|
||||||
createdAt: '2024-01-01'
|
createdAt: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
|
|
@ -303,9 +295,8 @@ const stats = ref({
|
||||||
const showEditModal = ref(false);
|
const showEditModal = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const editForm = ref({
|
const editForm = ref({
|
||||||
fullName: '',
|
firstName: '',
|
||||||
email: '',
|
lastName: '',
|
||||||
username: '',
|
|
||||||
phone: ''
|
phone: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -351,11 +342,15 @@ const handleUpdateProfile = async () => {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Call API to update profile
|
// Call real API to update profile
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await userService.updateProfile({
|
||||||
|
first_name: editForm.value.firstName,
|
||||||
|
last_name: editForm.value.lastName,
|
||||||
|
phone: editForm.value.phone || null
|
||||||
|
});
|
||||||
|
|
||||||
// Update local data
|
// Refresh profile data from API
|
||||||
profile.value = { ...profile.value, ...editForm.value };
|
await fetchProfile();
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -364,7 +359,7 @@ const handleUpdateProfile = async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
showEditModal.value = false;
|
showEditModal.value = false;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
|
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
|
||||||
|
|
@ -379,8 +374,11 @@ const handleChangePassword = async () => {
|
||||||
changingPassword.value = true;
|
changingPassword.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Call API to change password
|
// Call real API to change password
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await userService.changePassword(
|
||||||
|
passwordForm.value.currentPassword,
|
||||||
|
passwordForm.value.newPassword
|
||||||
|
);
|
||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
@ -394,10 +392,12 @@ const handleChangePassword = async () => {
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'รหัสผ่านปัจจุบันไม่ถูกต้อง',
|
message: error.response?.status === 401
|
||||||
|
? 'รหัสผ่านปัจจุบันไม่ถูกต้อง'
|
||||||
|
: 'เกิดข้อผิดพลาดในการเปลี่ยนรหัสผ่าน',
|
||||||
position: 'top'
|
position: 'top'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -408,12 +408,46 @@ const handleChangePassword = async () => {
|
||||||
// Watch edit modal
|
// Watch edit modal
|
||||||
watch(showEditModal, (newVal) => {
|
watch(showEditModal, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
|
// Split fullName into firstName and lastName for editing
|
||||||
|
const nameParts = profile.value.fullName.split(' ');
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
fullName: profile.value.fullName,
|
firstName: nameParts[0] || '',
|
||||||
email: profile.value.email,
|
lastName: nameParts.slice(1).join(' ') || '',
|
||||||
username: profile.value.username,
|
|
||||||
phone: profile.value.phone
|
phone: profile.value.phone
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch profile from API
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await userService.getProfile();
|
||||||
|
|
||||||
|
// Map API response to profile
|
||||||
|
profile.value = {
|
||||||
|
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
|
||||||
|
email: data.email,
|
||||||
|
username: data.username,
|
||||||
|
phone: data.profile.phone || '',
|
||||||
|
role: data.role.code,
|
||||||
|
roleName: data.role.name.th,
|
||||||
|
avatar: data.profile.avatar_url || '👨🏫',
|
||||||
|
createdAt: data.created_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลโปรไฟล์ได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load profile on mount
|
||||||
|
onMounted(() => {
|
||||||
|
fetchProfile();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,11 @@
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<q-checkbox v-model="rememberMe" label="จดจำฉันไว้" />
|
<q-checkbox v-model="rememberMe" label="จดจำฉันไว้" />
|
||||||
<a href="#" class="text-sm text-primary-600 hover:text-primary-700">ลืมรหัสผ่าน?</a>
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-sm text-primary-600 hover:text-primary-700"
|
||||||
|
@click.prevent="showForgotModal = true"
|
||||||
|
>ลืมรหัสผ่าน?</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -54,22 +58,78 @@
|
||||||
<p>ทดสอบ: admin@elearning.local / instructor@elearning.local</p>
|
<p>ทดสอบ: admin@elearning.local / instructor@elearning.local</p>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Forgot Password Modal -->
|
||||||
|
<q-dialog v-model="showForgotModal" persistent>
|
||||||
|
<q-card style="min-width: 400px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">ลืมรหัสผ่าน</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense @click="showForgotModal = false" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
กรอกอีเมลของคุณ เราจะส่งลิงก์สำหรับรีเซ็ตรหัสผ่านไปให้
|
||||||
|
</p>
|
||||||
|
<q-form @submit="handleForgotPassword">
|
||||||
|
<q-input
|
||||||
|
v-model="forgotEmail"
|
||||||
|
label="อีเมล"
|
||||||
|
type="email"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="email" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="ยกเลิก"
|
||||||
|
color="grey-7"
|
||||||
|
@click="showForgotModal = false"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
label="ส่งลิงก์รีเซ็ต"
|
||||||
|
color="primary"
|
||||||
|
:loading="forgotLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth'
|
layout: 'auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Login form
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const rememberMe = ref(false);
|
const rememberMe = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// Forgot password
|
||||||
|
const showForgotModal = ref(false);
|
||||||
|
const forgotEmail = ref('');
|
||||||
|
const forgotLoading = ref(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -86,14 +146,38 @@ const handleLogin = async () => {
|
||||||
} else if (authStore.isAdmin) {
|
} else if (authStore.isAdmin) {
|
||||||
router.push('/admin');
|
router.push('/admin');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
|
message: error.message || 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
|
||||||
position: 'top'
|
position: 'top'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleForgotPassword = async () => {
|
||||||
|
forgotLoading.value = true;
|
||||||
|
try {
|
||||||
|
await authService.forgotPassword(forgotEmail.value);
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ส่งลิงก์รีเซ็ตรหัสผ่านไปยังอีเมลของคุณแล้ว',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
|
||||||
|
showForgotModal.value = false;
|
||||||
|
forgotEmail.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
forgotLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
138
frontend_management/pages/reset-password.vue
Normal file
138
frontend_management/pages/reset-password.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-6 items-center justify-center">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">E-Learning</h1>
|
||||||
|
<p class="text-gray-600 mt-2">ตั้งรหัสผ่านใหม่</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div v-if="success" class="text-center">
|
||||||
|
<q-icon name="check_circle" size="80px" color="positive" class="mb-4" />
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-2">รีเซ็ตรหัสผ่านสำเร็จ!</h2>
|
||||||
|
<p class="text-gray-600 mb-6">คุณสามารถเข้าสู่ระบบด้วยรหัสผ่านใหม่ได้แล้ว</p>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="ไปหน้าเข้าสู่ระบบ"
|
||||||
|
@click="router.push('/login')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Form -->
|
||||||
|
<div v-else>
|
||||||
|
<q-form @submit="handleResetPassword" class="space-y-4">
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
label="รหัสผ่านใหม่"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
outlined
|
||||||
|
:rules="[
|
||||||
|
val => !!val || 'กรุณากรอกรหัสผ่าน',
|
||||||
|
val => val.length >= 8 || 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="lock" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
label="ยืนยันรหัสผ่านใหม่"
|
||||||
|
:type="showConfirmPassword ? 'text' : 'password'"
|
||||||
|
outlined
|
||||||
|
:rules="[
|
||||||
|
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
||||||
|
val => val === password || 'รหัสผ่านไม่ตรงกัน'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="lock" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
:name="showConfirmPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="showConfirmPassword = !showConfirmPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
label="ตั้งรหัสผ่านใหม่"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</q-form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700">
|
||||||
|
กลับหน้าเข้าสู่ระบบ
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { authService } from '~/services/auth.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'auth'
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Form
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
const showPassword = ref(false);
|
||||||
|
const showConfirmPassword = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const success = ref(false);
|
||||||
|
|
||||||
|
// Get token from URL
|
||||||
|
const token = computed(() => route.query.token as string);
|
||||||
|
|
||||||
|
// Check if token exists
|
||||||
|
onMounted(() => {
|
||||||
|
if (!token.value) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ลิงก์รีเซ็ตรหัสผ่านไม่ถูกต้อง',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResetPassword = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await authService.resetPassword(token.value, password.value);
|
||||||
|
success.value = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
11
frontend_management/plugins/01.auth.ts
Normal file
11
frontend_management/plugins/01.auth.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default defineNuxtPlugin({
|
||||||
|
name: 'auth-restore',
|
||||||
|
parallel: false,
|
||||||
|
setup() {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// Restore auth state from cookies on app initialization
|
||||||
|
// useCookie works on both server and client
|
||||||
|
authStore.checkAuth();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export default defineNuxtPlugin(() => {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// Restore auth state from localStorage before any navigation
|
|
||||||
if (process.client) {
|
|
||||||
authStore.checkAuth();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
358
frontend_management/services/admin.service.ts
Normal file
358
frontend_management/services/admin.service.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
// API Response structure for user list
|
||||||
|
export interface AdminUserResponse {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
role: {
|
||||||
|
code: string;
|
||||||
|
name: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
profile: {
|
||||||
|
prefix: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
birth_date: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersListResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: AdminUserResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data for development
|
||||||
|
const MOCK_USERS: AdminUserResponse[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
email: 'admin@elearning.local',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
role: { code: 'ADMIN', name: { en: 'Administrator', th: 'ผู้ดูแลระบบ' } },
|
||||||
|
profile: {
|
||||||
|
prefix: { en: 'Mr.', th: 'นาย' },
|
||||||
|
first_name: 'Admin',
|
||||||
|
last_name: 'User',
|
||||||
|
avatar_url: null,
|
||||||
|
birth_date: null,
|
||||||
|
phone: '0812345678'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'instructor',
|
||||||
|
email: 'instructor@elearning.local',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
role: { code: 'INSTRUCTOR', name: { en: 'Instructor', th: 'ผู้สอน' } },
|
||||||
|
profile: {
|
||||||
|
prefix: { en: 'Mr.', th: 'นาย' },
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Instructor',
|
||||||
|
avatar_url: null,
|
||||||
|
birth_date: null,
|
||||||
|
phone: '0812345679'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
username: 'student',
|
||||||
|
email: 'student@elearning.local',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
role: { code: 'STUDENT', name: { en: 'Student', th: 'นักเรียน' } },
|
||||||
|
profile: {
|
||||||
|
prefix: { en: 'Ms.', th: 'นางสาว' },
|
||||||
|
first_name: 'Jane',
|
||||||
|
last_name: 'Student',
|
||||||
|
avatar_url: null,
|
||||||
|
birth_date: null,
|
||||||
|
phone: '0812345680'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper function to get auth token from cookie or localStorage
|
||||||
|
const getAuthToken = (): string => {
|
||||||
|
const tokenCookie = useCookie('token');
|
||||||
|
return tokenCookie.value || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminService = {
|
||||||
|
async getUsers(): Promise<AdminUserResponse[]> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_USERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<UsersListResponse>('/api/admin/usermanagement/users', {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserById(id: number): Promise<AdminUserResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
const user = MOCK_USERS.find(u => u.id === id);
|
||||||
|
if (!user) throw new Error('User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<AdminUserResponse>(`/api/admin/usermanagement/users/${id}`, {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUserRole(userId: number, roleId: number): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch(`/api/admin/usermanagement/role/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: userId,
|
||||||
|
role_id: roleId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUser(userId: number): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch(`/api/admin/usermanagement/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ Categories ============
|
||||||
|
async getCategories(): Promise<CategoryResponse[]> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<CategoriesListResponse>('/api/admin/categories', {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.categories;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCategory(data: CreateCategoryRequest): Promise<CategoryResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return { ...MOCK_CATEGORIES[0], id: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<CategoryResponse>('/api/admin/categories', {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCategory(id: number, data: UpdateCategoryRequest): Promise<CategoryResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return { ...MOCK_CATEGORIES[0], id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<CategoryResponse>(`/api/admin/categories/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCategory(id: number): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch(`/api/admin/categories/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category interfaces
|
||||||
|
export interface CategoryResponse {
|
||||||
|
id: number;
|
||||||
|
name: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
description: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
icon: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
created_by: number;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoriesListResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
categories: CategoryResponse[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryRequest {
|
||||||
|
name: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
description: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
created_by?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryRequest {
|
||||||
|
id: number;
|
||||||
|
name: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
description: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock categories
|
||||||
|
const MOCK_CATEGORIES: CategoryResponse[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: { en: 'Web Development', th: 'การพัฒนาเว็บไซต์' },
|
||||||
|
slug: 'web-development',
|
||||||
|
description: { en: 'Learn web development', th: 'หลักสูตรเกี่ยวกับการพัฒนาเว็บไซต์และเว็บแอปพลิเคชัน' },
|
||||||
|
icon: 'code',
|
||||||
|
sort_order: 1,
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
created_by: 1,
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_by: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: { en: 'Mobile Development', th: 'การพัฒนาแอปพลิเคชันมือถือ' },
|
||||||
|
slug: 'mobile-development',
|
||||||
|
description: { en: 'Learn mobile app development', th: 'หลักสูตรเกี่ยวกับการพัฒนาแอปพลิเคชันบนมือถือ' },
|
||||||
|
icon: 'smartphone',
|
||||||
|
sort_order: 2,
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2024-01-20T00:00:00Z',
|
||||||
|
created_by: 1,
|
||||||
|
updated_at: '2024-01-20T00:00:00Z',
|
||||||
|
updated_by: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: { en: 'Database', th: 'ฐานข้อมูล' },
|
||||||
|
slug: 'database',
|
||||||
|
description: { en: 'Learn database management', th: 'หลักสูตรเกี่ยวกับการออกแบบและจัดการฐานข้อมูล' },
|
||||||
|
icon: 'storage',
|
||||||
|
sort_order: 3,
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2024-02-01T00:00:00Z',
|
||||||
|
created_by: 1,
|
||||||
|
updated_at: '2024-02-01T00:00:00Z',
|
||||||
|
updated_by: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -41,14 +41,72 @@ export interface LoginResponse {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
fullName: string;
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mock data for development
|
||||||
|
const MOCK_USERS = [
|
||||||
|
{
|
||||||
|
email: 'admin@elearning.local',
|
||||||
|
password: 'password',
|
||||||
|
user: {
|
||||||
|
id: '1',
|
||||||
|
email: 'admin@elearning.local',
|
||||||
|
firstName: 'ผู้ดูแล',
|
||||||
|
lastName: 'ระบบ',
|
||||||
|
role: 'ADMIN'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'instructor@elearning.local',
|
||||||
|
password: 'password',
|
||||||
|
user: {
|
||||||
|
id: '2',
|
||||||
|
email: 'instructor@elearning.local',
|
||||||
|
firstName: 'อาจารย์',
|
||||||
|
lastName: 'ทดสอบ',
|
||||||
|
role: 'INSTRUCTOR'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
async login(email: string, password: string): Promise<LoginResponse> {
|
async login(email: string, password: string): Promise<LoginResponse> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
// Use Mock Data
|
||||||
|
if (useMockData) {
|
||||||
|
return this.mockLogin(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Real API
|
||||||
|
return this.apiLogin(email, password);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mock login for development
|
||||||
|
async mockLogin(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate delay
|
||||||
|
|
||||||
|
const mockUser = MOCK_USERS.find(u => u.email === email);
|
||||||
|
|
||||||
|
if (!mockUser || mockUser.password !== password) {
|
||||||
|
throw new Error('อีเมลหรือรหัสผ่านไม่ถูกต้อง');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: 'mock-jwt-token',
|
||||||
|
refreshToken: 'mock-refresh-token',
|
||||||
|
user: mockUser.user
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Real API login
|
||||||
|
async apiLogin(email: string, password: string): Promise<LoginResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch<ApiLoginResponse>('/api/auth/login', {
|
const response = await $fetch<ApiLoginResponse>('/api/auth/login', {
|
||||||
|
|
@ -60,6 +118,11 @@ export const authService = {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if user role is STUDENT - block login
|
||||||
|
if (response.user.role.code === 'STUDENT') {
|
||||||
|
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
|
||||||
|
}
|
||||||
|
|
||||||
// Transform API response to frontend format
|
// Transform API response to frontend format
|
||||||
return {
|
return {
|
||||||
token: response.token,
|
token: response.token,
|
||||||
|
|
@ -67,26 +130,68 @@ export const authService = {
|
||||||
user: {
|
user: {
|
||||||
id: response.user.id.toString(),
|
id: response.user.id.toString(),
|
||||||
email: response.user.email,
|
email: response.user.email,
|
||||||
fullName: `${response.user.profile.first_name} ${response.user.profile.last_name}`,
|
firstName: response.user.profile.first_name,
|
||||||
role: response.user.role.code // Use role.code (ADMIN, INSTRUCTOR, etc.)
|
lastName: response.user.profile.last_name,
|
||||||
|
role: response.user.role.code
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Re-throw custom errors (like STUDENT role block)
|
||||||
|
if (error.message && !error.response) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
// Handle API errors
|
// Handle API errors
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
throw new Error('Invalid credentials');
|
throw new Error('อีเมลหรือรหัสผ่านไม่ถูกต้อง');
|
||||||
}
|
}
|
||||||
throw new Error('Login failed');
|
throw new Error('เกิดข้อผิดพลาดในการเข้าสู่ระบบ');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
// TODO: Call logout API if available
|
// Clear cookies
|
||||||
// For now, just clear local storage
|
const tokenCookie = useCookie('token');
|
||||||
if (process.client) {
|
const refreshTokenCookie = useCookie('refreshToken');
|
||||||
localStorage.removeItem('token');
|
const userCookie = useCookie('user');
|
||||||
localStorage.removeItem('refreshToken');
|
|
||||||
localStorage.removeItem('user');
|
tokenCookie.value = null;
|
||||||
|
refreshTokenCookie.value = null;
|
||||||
|
userCookie.value = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async forgotPassword(email: string): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
// Mock: simulate sending email
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real API
|
||||||
|
await $fetch('/api/auth/reset-request', {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
body: { email }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetPassword(token: string, password: string): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
// Mock: simulate reset password
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real API
|
||||||
|
await $fetch('/api/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
body: { token, password }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
182
frontend_management/services/user.service.ts
Normal file
182
frontend_management/services/user.service.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// API Response structure from /api/user/me
|
||||||
|
export interface UserProfileResponse {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
updated_at: string;
|
||||||
|
created_at: string;
|
||||||
|
role: {
|
||||||
|
code: string;
|
||||||
|
name: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
profile: {
|
||||||
|
prefix: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
birth_date: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request body for PUT /api/user/me
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
prefix?: {
|
||||||
|
en: string;
|
||||||
|
th: string;
|
||||||
|
};
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
birth_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock profile data for development
|
||||||
|
const MOCK_PROFILES: Record<string, UserProfileResponse> = {
|
||||||
|
'admin@elearning.local': {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
email: 'admin@elearning.local',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
role: {
|
||||||
|
code: 'ADMIN',
|
||||||
|
name: { en: 'Administrator', th: 'ผู้ดูแลระบบ' }
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
prefix: { en: 'Mr.', th: 'นาย' },
|
||||||
|
first_name: 'ผู้ดูแล',
|
||||||
|
last_name: 'ระบบ',
|
||||||
|
avatar_url: null,
|
||||||
|
birth_date: null,
|
||||||
|
phone: '081-234-5678'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'instructor@elearning.local': {
|
||||||
|
id: 2,
|
||||||
|
username: 'instructor',
|
||||||
|
email: 'instructor@elearning.local',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
role: {
|
||||||
|
code: 'INSTRUCTOR',
|
||||||
|
name: { en: 'Instructor', th: 'ผู้สอน' }
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
prefix: { en: 'Mr.', th: 'นาย' },
|
||||||
|
first_name: 'อาจารย์',
|
||||||
|
last_name: 'ทดสอบ',
|
||||||
|
avatar_url: null,
|
||||||
|
birth_date: null,
|
||||||
|
phone: '081-234-5678'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get auth token from cookie
|
||||||
|
const getAuthToken = (): string => {
|
||||||
|
const tokenCookie = useCookie('token');
|
||||||
|
return tokenCookie.value || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userService = {
|
||||||
|
async getProfile(): Promise<UserProfileResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
return this.mockGetProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.apiGetProfile();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mock get profile
|
||||||
|
async mockGetProfile(): Promise<UserProfileResponse> {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
const userCookie = useCookie('user');
|
||||||
|
if (!userCookie.value) throw new Error('User not found');
|
||||||
|
|
||||||
|
const user = typeof userCookie.value === 'string'
|
||||||
|
? JSON.parse(userCookie.value)
|
||||||
|
: userCookie.value;
|
||||||
|
const mockProfile = MOCK_PROFILES[user.email];
|
||||||
|
|
||||||
|
if (!mockProfile) {
|
||||||
|
throw new Error('Profile not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockProfile;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Real API get profile
|
||||||
|
async apiGetProfile(): Promise<UserProfileResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
const response = await $fetch<UserProfileResponse>('/api/user/me', {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProfile(data: UpdateProfileRequest): Promise<UserProfileResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
// In mock mode, just return the current profile with updates
|
||||||
|
const profile = await this.mockGetProfile();
|
||||||
|
return { ...profile, profile: { ...profile.profile, ...data } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<UserProfileResponse>('/api/user/me', {
|
||||||
|
method: 'PUT',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: data
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async changePassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
// In mock mode, just simulate success
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch('/api/user/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
oldPassword,
|
||||||
|
newPassword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,8 @@ import { authService } from '~/services/auth.service';
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
fullName: string;
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
role: 'INSTRUCTOR' | 'ADMIN' | 'STUDENT';
|
role: 'INSTRUCTOR' | 'ADMIN' | 'STUDENT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,23 +25,32 @@ export const useAuthStore = defineStore('auth', {
|
||||||
actions: {
|
actions: {
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
try {
|
try {
|
||||||
// Call real API
|
|
||||||
const response = await authService.login(email, password);
|
const response = await authService.login(email, password);
|
||||||
|
|
||||||
this.token = response.token;
|
this.token = response.token;
|
||||||
this.user = response.user as User;
|
this.user = response.user as User;
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
|
|
||||||
// Save to localStorage (including refreshToken)
|
// Save to cookies
|
||||||
if (process.client) {
|
const tokenCookie = useCookie('token', {
|
||||||
localStorage.setItem('token', this.token);
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
localStorage.setItem('refreshToken', response.refreshToken);
|
sameSite: 'strict'
|
||||||
localStorage.setItem('user', JSON.stringify(this.user));
|
});
|
||||||
}
|
const refreshTokenCookie = useCookie('refreshToken', {
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
sameSite: 'strict'
|
||||||
|
});
|
||||||
|
const userCookie = useCookie('user', {
|
||||||
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
sameSite: 'strict'
|
||||||
|
});
|
||||||
|
|
||||||
|
tokenCookie.value = this.token;
|
||||||
|
refreshTokenCookie.value = response.refreshToken;
|
||||||
|
userCookie.value = JSON.stringify(this.user);
|
||||||
|
|
||||||
return { token: this.token, user: this.user };
|
return { token: this.token, user: this.user };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Re-throw error to be handled by login page
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -50,21 +60,30 @@ export const useAuthStore = defineStore('auth', {
|
||||||
this.token = null;
|
this.token = null;
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
|
|
||||||
if (process.client) {
|
// Clear cookies
|
||||||
localStorage.removeItem('token');
|
const tokenCookie = useCookie('token');
|
||||||
localStorage.removeItem('user');
|
const refreshTokenCookie = useCookie('refreshToken');
|
||||||
}
|
const userCookie = useCookie('user');
|
||||||
|
|
||||||
|
tokenCookie.value = null;
|
||||||
|
refreshTokenCookie.value = null;
|
||||||
|
userCookie.value = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth() {
|
checkAuth() {
|
||||||
if (process.client) {
|
const tokenCookie = useCookie('token');
|
||||||
const token = localStorage.getItem('token');
|
const userCookie = useCookie('user');
|
||||||
const user = localStorage.getItem('user');
|
|
||||||
|
|
||||||
if (token && user) {
|
if (tokenCookie.value && userCookie.value) {
|
||||||
this.token = token;
|
this.token = tokenCookie.value;
|
||||||
this.user = JSON.parse(user);
|
try {
|
||||||
|
this.user = typeof userCookie.value === 'string'
|
||||||
|
? JSON.parse(userCookie.value)
|
||||||
|
: userCookie.value;
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid user data
|
||||||
|
this.logout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue