login with api
This commit is contained in:
parent
d8a9909eb9
commit
ff5b189b2f
16 changed files with 1241 additions and 66 deletions
59
frontend_management/components/common/AppCard.vue
Normal file
59
frontend_management/components/common/AppCard.vue
Normal 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>
|
||||
53
frontend_management/components/common/AppHeader.vue
Normal file
53
frontend_management/components/common/AppHeader.vue
Normal 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>
|
||||
101
frontend_management/components/common/AppModal.vue
Normal file
101
frontend_management/components/common/AppModal.vue
Normal 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>
|
||||
177
frontend_management/components/common/AppTable.vue
Normal file
177
frontend_management/components/common/AppTable.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue