login with api

This commit is contained in:
Missez 2026-01-14 13:58:25 +07:00
parent d8a9909eb9
commit ff5b189b2f
16 changed files with 1241 additions and 66 deletions

View file

@ -0,0 +1,59 @@
<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>

View file

@ -0,0 +1,53 @@
<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>

View file

@ -0,0 +1,101 @@
<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>

View file

@ -0,0 +1,177 @@
<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>

View file

@ -0,0 +1,34 @@
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
};
};

View file

@ -0,0 +1,83 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Sidebar -->
<aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg">
<div class="p-6">
<h2 class="text-xl font-bold text-primary-600">E-Learning</h2>
<p class="text-sm text-gray-500">Admin Panel</p>
</div>
<nav class="px-4">
<NuxtLink
to="/admin"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<q-icon name="dashboard" size="24px" />
<span>Dashboard</span>
</NuxtLink>
<NuxtLink
to="/admin/courses"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<q-icon name="school" size="24px" />
<span>ดการหลกสตร</span>
</NuxtLink>
<NuxtLink
to="/admin/users"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<q-icon name="people" size="24px" />
<span>ดการผใช</span>
</NuxtLink>
<NuxtLink
to="/admin/categories"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<q-icon name="folder" size="24px" />
<span>หมวดหม</span>
</NuxtLink>
<NuxtLink
to="/admin/settings"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<q-icon name="settings" size="24px" />
<span>งคาระบบ</span>
</NuxtLink>
</nav>
<!-- <div class="absolute bottom-0 left-0 right-0 p-4 border-t">
<button
@click="handleLogout"
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
>
<span>🚪</span>
<span>ออกจากระบบ</span>
</button>
</div> -->
</aside>
<!-- Main Content -->
<main class="ml-64 p-8">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
const router = useRouter();
const handleLogout = () => {
authStore.logout();
router.push('/login');
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-blue-100 to-indigo-100">
<!-- Background Pattern (Optional) -->
<div class="absolute inset-0 overflow-hidden opacity-10">
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-500 rounded-full blur-3xl"></div>
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-secondary-500 rounded-full blur-3xl"></div>
</div>
<!-- Auth Container -->
<div class="relative z-10 w-full max-w-md px-6">
<!-- Logo/Brand -->
<div class="text-center mb-8">
</div>
<!-- Auth Card -->
<q-card class="shadow-2xl rounded-2xl overflow-hidden">
<q-card-section class="p-1">
<slot></slot>
</q-card-section>
</q-card>
<!-- Footer -->
<div class="text-center mt-6 text-sm text-gray-600">
</div>
</div>
</div>
</template>
<script setup lang="ts">
// No props needed - pure layout component
</script>
<style scoped>
/* Optional: Add custom animations */
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
</style>

View file

@ -10,42 +10,24 @@
<nav class="px-4">
<NuxtLink
to="/instructor"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 shadow-md transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600 "
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<span>📊</span>
<q-icon name="dashboard" size="24px" />
<span>Dashboard</span>
</NuxtLink>
<NuxtLink
to="/instructor/courses"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<span>📚</span>
<q-icon name="school" size="24px" />
<span>หลกสตร</span>
</NuxtLink>
<NuxtLink
to="/instructor/announcements"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<span>📢</span>
<span>ประกาศ</span>
</NuxtLink>
<NuxtLink
to="/instructor/reports"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<span>📈</span>
<span>รายงาน</span>
</NuxtLink>
</nav>
<div class="absolute bottom-0 left-0 right-0 p-4 border-t">
<!-- <div class="absolute bottom-0 left-0 right-0 p-4 border-t">
<button
@click="handleLogout"
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
@ -53,7 +35,7 @@
<span>🚪</span>
<span>ออกจากระบบ</span>
</button>
</div>
</div> -->
</aside>
<!-- Main Content -->

View file

@ -29,8 +29,8 @@ export default defineNuxtConfig({
],
runtimeConfig: {
public: {
apiBase: process.env.API_BASE_URL || 'http://localhost:3001/api',
useMockData: process.env.USE_MOCK_DATA === 'true'
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL,
useMockData: process.env.NUXT_PUBLIC_USE_MOCK_DATA === 'true'
}
},
devtools: { enabled: true },

View file

@ -1,7 +1,59 @@
<template>
<div>
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
<p class="text-gray-600 mb-8">นดอนร, {{ authStore.user?.fullName }}</p>
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-gray-900">
สวสด, {{ authStore.user?.fullName || 'ผู้ดูแลระบบ' }}
</h1>
<p class="text-gray-600 mt-2">นดอนรบกลบสระบบ</p>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.fullName || 'ผู้ดูแลระบบ' }}</div>
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'ADMIN' }}</div>
</div>
<div
class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition"
>
👨💼
<q-menu>
<q-list style="min-width: 200px">
<!-- User Info Header -->
<q-item class="bg-primary-50">
<q-item-section>
<q-item-label class="text-weight-bold">{{ authStore.user?.fullName }}</q-item-label>
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<!-- Profile -->
<q-item clickable v-close-popup @click="goToProfile">
<q-item-section avatar>
<q-icon name="person" />
</q-item-section>
<q-item-section>โปรไฟล</q-item-section>
</q-item>
<q-separator />
<!-- Logout -->
<q-item clickable v-close-popup @click="handleLogout">
<q-item-section avatar>
<q-icon name="logout" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ออกจากระบบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card">
@ -28,7 +80,6 @@
<p class="text-gray-600 text-sm">เรยนจบแล</p>
<p class="text-3xl font-bold text-accent-500">45</p>
</div>
<q-icon name="emoji_events" size="48px" class="text-accent-200" />
</div>
</div>
</div>
@ -52,8 +103,20 @@
</template>
<script setup lang="ts">
definePageMeta({
layout: 'instructor',
middleware: 'instructor'
layout: 'admin',
middleware: 'auth'
});
const authStore = useAuthStore();
const router = useRouter();
// Navigation functions
const goToProfile = () => {
router.push('/admin/profile');
};
const handleLogout = () => {
authStore.logout();
router.push('/login');
};
</script>

View file

@ -1,14 +1,55 @@
<template>
<div>
<!-- Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">สวสด, อาจารยทดสอบ 👋</h1>
<p class="text-gray-600 mt-2">นดอนรบกลบสระบบ</p>
</div>
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl">
👤
<div class="mb-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-gray-900">
สวสด, {{ authStore.user?.fullName || 'อาจารย์' }}
</h1>
<p class="text-gray-600 mt-2">นดอนรบกลบสระบบ</p>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.fullName || 'อาจารย์ทดสอบ' }}</div>
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div>
</div>
<div
class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl cursor-pointer hover:bg-primary-200 transition"
>
👨🏫
<q-menu>
<q-list style="min-width: 200px">
<!-- User Info Header -->
<q-item class="bg-primary-50">
<q-item-section>
<q-item-label class="text-weight-bold">{{ authStore.user?.fullName }}</q-item-label>
<q-item-label caption>{{ authStore.user?.email }}</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<!-- Profile -->
<q-item clickable v-close-popup @click="goToProfile">
<q-item-section avatar>
<q-icon name="person" />
</q-item-section>
<q-item-section>โปรไฟล</q-item-section>
</q-item>
<q-separator />
<!-- Logout -->
<q-item clickable v-close-popup @click="handleLogout">
<q-item-section avatar>
<q-icon name="logout" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ออกจากระบบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
</div>
</div>
</div>
@ -96,9 +137,24 @@ definePageMeta({
middleware: 'auth'
});
const authStore = useAuthStore();
const instructorStore = useInstructorStore();
const router = useRouter();
// Navigation functions
const goToProfile = () => {
router.push('/instructor/profile');
};
const goToSettings = () => {
router.push('/instructor/settings');
};
const handleLogout = () => {
authStore.logout();
router.push('/login');
};
// Fetch dashboard data on mount
onMounted(() => {
instructorStore.fetchDashboardData();

View file

@ -0,0 +1,419 @@
<template>
<div>
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900">โปรไฟลของฉ</h1>
<p class="text-gray-600 mt-2">ดการขอมลสวนตวของค</p>
</div>
<!-- Profile Card -->
<AppCard class="mb-10 ">
<div class="flex flex-col md:flex-row gap-8">
<!-- Avatar Section -->
<div class="flex flex-col items-center">
<div class="w-32 h-32 bg-primary-100 rounded-full flex items-center justify-center text-6xl mb-4">
{{ profile.avatar }}
</div>
<q-btn
outline
color="primary"
label="เปลี่ยนรูป"
icon="photo_camera"
@click="handleAvatarUpload"
/>
</div>
<!-- Profile Info -->
<div class="flex-1">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-600 mb-1">-นามสก</div>
<div class="text-lg font-semibold text-gray-900">{{ profile.fullName }}</div>
</div>
<div>
<div class="text-sm text-gray-600 mb-1">เมล</div>
<div class="text-lg text-gray-900">{{ profile.email }}</div>
</div>
<div>
<div class="text-sm text-gray-600 mb-1">Username</div>
<div class="text-lg text-gray-900">{{ profile.username }}</div>
</div>
<div>
<div class="text-sm text-gray-600 mb-1">เบอรโทร</div>
<div class="text-lg text-gray-900">{{ profile.phone || '-' }}</div>
</div>
<div>
<div class="text-sm text-gray-600 mb-1">ตำแหน</div>
<div class="text-lg text-gray-900">
<q-badge color="primary">{{ getRoleLabel(profile.role) }}</q-badge>
</div>
</div>
<div>
<div class="text-sm text-gray-600 mb-1">นทสมคร</div>
<div class="text-lg text-gray-900">{{ formatDate(profile.createdAt) }}</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 mt-6">
<q-btn
color="primary"
label="แก้ไขโปรไฟล์"
icon="edit"
@click="showEditModal = true"
/>
<q-btn
outline
color="grey-7"
label="เปลี่ยนรหัสผ่าน"
icon="lock"
@click="showPasswordModal = true"
/>
</div>
</div>
</div>
</AppCard>
<!-- Stats Cards
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5">
<AppCard>
<div class="text-center py-4">
<div class="text-4xl font-bold text-primary-600 mb-2">{{ stats.totalCourses }}</div>
<div class="text-gray-600">หลกสตรทสราง</div>
</div>
</AppCard>
<AppCard>
<div class="text-center py-4">
<div class="text-4xl font-bold text-secondary-600 mb-2">{{ stats.totalStudents }}</div>
<div class="text-gray-600">เรยนทงหมด</div>
</div>
</AppCard>
</div> -->
<!-- Edit Profile Modal -->
<q-dialog v-model="showEditModal" persistent>
<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="showEditModal = false" />
</q-card-section>
<q-card-section>
<q-form @submit="handleUpdateProfile">
<q-input
v-model="editForm.fullName"
label="ชื่อ-นามสกุล"
outlined
class="mb-4"
:rules="[val => !!val || 'กรุณากรอกชื่อ-นามสกุล']"
>
<template v-slot:prepend>
<q-icon name="person" />
</template>
</q-input>
<q-input
v-model="editForm.email"
label="อีเมล"
type="email"
outlined
class="mb-4"
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
>
<template v-slot:prepend>
<q-icon name="email" />
</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>
</q-input>
<q-input
v-model="editForm.phone"
label="เบอร์โทร"
outlined
class="mb-4"
>
<template v-slot:prepend>
<q-icon name="phone" />
</template>
</q-input>
<div class="flex justify-end gap-2 mt-4">
<q-btn
flat
label="ยกเลิก"
color="grey-7"
@click="showEditModal = false"
/>
<q-btn
type="submit"
label="บันทึก"
color="primary"
:loading="saving"
/>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
<!-- Change Password Modal -->
<q-dialog v-model="showPasswordModal" persistent>
<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="showPasswordModal = false" />
</q-card-section>
<q-card-section>
<q-form @submit="handleChangePassword">
<q-input
v-model="passwordForm.currentPassword"
label="รหัสผ่านปัจจุบัน"
:type="showCurrentPassword ? 'text' : 'password'"
outlined
class="mb-4"
:rules="[val => !!val || 'กรุณากรอกรหัสผ่านปัจจุบัน']"
>
<template v-slot:prepend>
<q-icon name="lock" />
</template>
<template v-slot:append>
<q-icon
:name="showCurrentPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showCurrentPassword = !showCurrentPassword"
/>
</template>
</q-input>
<q-input
v-model="passwordForm.newPassword"
label="รหัสผ่านใหม่"
:type="showNewPassword ? 'text' : 'password'"
outlined
class="mb-4"
:rules="[
val => !!val || 'กรุณากรอกรหัสผ่านใหม่',
val => val.length >= 6 || 'รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร'
]"
>
<template v-slot:prepend>
<q-icon name="lock" />
</template>
<template v-slot:append>
<q-icon
:name="showNewPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showNewPassword = !showNewPassword"
/>
</template>
</q-input>
<q-input
v-model="passwordForm.confirmPassword"
label="ยืนยันรหัสผ่านใหม่"
:type="showConfirmPassword ? 'text' : 'password'"
outlined
class="mb-4"
:rules="[
val => !!val || 'กรุณายืนยันรหัสผ่าน',
val => val === passwordForm.newPassword || 'รหัสผ่านไม่ตรงกัน'
]"
>
<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>
<div class="flex justify-end gap-2 mt-4">
<q-btn
flat
label="ยกเลิก"
color="grey-7"
@click="showPasswordModal = false"
/>
<q-btn
type="submit"
label="เปลี่ยนรหัสผ่าน"
color="primary"
:loading="changingPassword"
/>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
definePageMeta({
layout: 'instructor',
middleware: 'auth'
});
const $q = useQuasar();
const authStore = useAuthStore();
// Profile data
const profile = ref({
fullName: 'อาจารย์ทดสอบ ระบบอีเลินนิ่ง',
email: 'instructor@example.com',
username: 'instructor_test',
phone: '081-234-5678',
role: 'INSTRUCTOR',
avatar: '👨‍🏫',
createdAt: '2024-01-01'
});
const stats = ref({
totalCourses: 5,
totalStudents: 125
});
// Edit form
const showEditModal = ref(false);
const saving = ref(false);
const editForm = ref({
fullName: '',
email: '',
username: '',
phone: ''
});
// Password form
const showPasswordModal = ref(false);
const changingPassword = ref(false);
const showCurrentPassword = ref(false);
const showNewPassword = ref(false);
const showConfirmPassword = ref(false);
const passwordForm = ref({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
// Methods
const getRoleLabel = (role: string) => {
const labels: Record<string, string> = {
INSTRUCTOR: 'ผู้สอน',
ADMIN: 'ผู้ดูแลระบบ',
STUDENT: 'ผู้เรียน'
};
return labels[role] || role;
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const handleAvatarUpload = () => {
$q.notify({
type: 'info',
message: 'ฟีเจอร์อัพโหลดรูปภาพจะพร้อมใช้งานเร็วๆ นี้',
position: 'top'
});
};
const handleUpdateProfile = async () => {
saving.value = true;
try {
// TODO: Call API to update profile
await new Promise(resolve => setTimeout(resolve, 1000));
// Update local data
profile.value = { ...profile.value, ...editForm.value };
$q.notify({
type: 'positive',
message: 'อัพเดทโปรไฟล์สำเร็จ',
position: 'top'
});
showEditModal.value = false;
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
position: 'top'
});
} finally {
saving.value = false;
}
};
const handleChangePassword = async () => {
changingPassword.value = true;
try {
// TODO: Call API to change password
await new Promise(resolve => setTimeout(resolve, 1000));
$q.notify({
type: 'positive',
message: 'เปลี่ยนรหัสผ่านสำเร็จ',
position: 'top'
});
showPasswordModal.value = false;
passwordForm.value = {
currentPassword: '',
newPassword: '',
confirmPassword: ''
};
} catch (error) {
$q.notify({
type: 'negative',
message: 'รหัสผ่านปัจจุบันไม่ถูกต้อง',
position: 'top'
});
} finally {
changingPassword.value = false;
}
};
// Watch edit modal
watch(showEditModal, (newVal) => {
if (newVal) {
editForm.value = {
fullName: profile.value.fullName,
email: profile.value.email,
username: profile.value.username,
phone: profile.value.phone
};
}
});
</script>

View file

@ -1,6 +1,5 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<q-card class="w-full max-w-md p-8 shadow-xl">
<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>
@ -36,6 +35,12 @@
/>
</template>
</q-input>
<div class="flex items-center justify-between">
<q-checkbox v-model="rememberMe" label="จดจำฉันไว้" />
<a href="#" class="text-sm text-primary-600 hover:text-primary-700">มรหสผาน?</a>
</div>
<q-btn
type="submit"
color="primary"
@ -46,12 +51,12 @@
/>
</q-form>
<div class="mt-6 text-center text-sm text-gray-600">
<p>ทดสอบ: instructor@test.com / admin@test.com</p>
<p>ทดสอบ: admin@elearning.local / instructor@elearning.local</p>
</div>
</q-card-section>
</q-card>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
definePageMeta({
@ -63,6 +68,7 @@ const router = useRouter();
const email = ref('');
const password = ref('');
const showPassword = ref(false);
const rememberMe = ref(false);
const loading = ref(false);
const handleLogin = async () => {
loading.value = true;

View file

@ -0,0 +1,8 @@
export default defineNuxtPlugin(() => {
const authStore = useAuthStore();
// Restore auth state from localStorage before any navigation
if (process.client) {
authStore.checkAuth();
}
});

View file

@ -0,0 +1,92 @@
export interface LoginRequest {
email: string;
password: string;
}
// API Response structure (from backend)
export interface ApiLoginResponse {
token: string;
refreshToken: string;
user: {
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;
phone: string | null;
avatar_url: string | null;
birth_date: string | null;
};
};
}
// Frontend User structure
export interface LoginResponse {
token: string;
refreshToken: string;
user: {
id: string;
email: string;
fullName: string;
role: string;
};
}
export const authService = {
async login(email: string, password: string): Promise<LoginResponse> {
const config = useRuntimeConfig();
try {
const response = await $fetch<ApiLoginResponse>('/api/auth/login', {
method: 'POST',
baseURL: config.public.apiBaseUrl as string,
body: {
email,
password
}
});
// Transform API response to frontend format
return {
token: response.token,
refreshToken: response.refreshToken,
user: {
id: response.user.id.toString(),
email: response.user.email,
fullName: `${response.user.profile.first_name} ${response.user.profile.last_name}`,
role: response.user.role.code // Use role.code (ADMIN, INSTRUCTOR, etc.)
}
};
} catch (error: any) {
// Handle API errors
if (error.response?.status === 401) {
throw new Error('Invalid credentials');
}
throw new Error('Login failed');
}
},
async logout(): Promise<void> {
// TODO: Call logout API if available
// For now, just clear local storage
if (process.client) {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
}
}
};

View file

@ -1,4 +1,5 @@
import { defineStore } from 'pinia';
import { authService } from '~/services/auth.service';
interface User {
id: string;
@ -22,32 +23,26 @@ export const useAuthStore = defineStore('auth', {
actions: {
async login(email: string, password: string) {
// TODO: Replace with real API call
// const { $api } = useNuxtApp();
// const response = await $api('/auth/login', {
// method: 'POST',
// body: { email, password }
// });
try {
// Call real API
const response = await authService.login(email, password);
// Mock login for development
const mockUser: User = {
id: '1',
email: email,
fullName: 'อาจารย์ทดสอบ',
role: 'INSTRUCTOR'
};
this.token = response.token;
this.user = response.user as User;
this.isAuthenticated = true;
this.token = 'mock-jwt-token';
this.user = mockUser;
this.isAuthenticated = true;
// Save to localStorage (including refreshToken)
if (process.client) {
localStorage.setItem('token', this.token);
localStorage.setItem('refreshToken', response.refreshToken);
localStorage.setItem('user', JSON.stringify(this.user));
}
// Save to localStorage
if (process.client) {
localStorage.setItem('token', this.token);
localStorage.setItem('user', JSON.stringify(this.user));
return { token: this.token, user: this.user };
} catch (error: any) {
// Re-throw error to be handled by login page
throw error;
}
return { token: this.token, user: this.user };
},
logout() {