698 lines
20 KiB
Vue
698 lines
20 KiB
Vue
<template>
|
|
<section class="system-admin-panel">
|
|
<div class="th-section filter-section">
|
|
<div class="th-section-header">
|
|
<h2 class="th-section-title">{{ t('systemAdmin.users.title') }}</h2>
|
|
<button
|
|
type="button"
|
|
class="text-button"
|
|
@click="resetFilters"
|
|
>
|
|
{{ t('workbench.reset') }}
|
|
</button>
|
|
</div>
|
|
<div class="filter-grid">
|
|
<label>
|
|
<span>{{ t('systemAdmin.common.keyword') }}</span>
|
|
<input
|
|
v-model="filters.keyword"
|
|
type="search"
|
|
:placeholder="t('systemAdmin.users.keywordPlaceholder')"
|
|
@input="resetToFirstPage"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.userStatus') }}</span>
|
|
<select
|
|
v-model="filters.user_status"
|
|
@change="resetToFirstPage"
|
|
>
|
|
<option value="">{{ t('common.all') }}</option>
|
|
<option value="ACTIVE">{{ t('systemAdmin.status.ACTIVE') }}</option>
|
|
<option value="DISABLED">{{ t('systemAdmin.status.DISABLED') }}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="th-section action-section">
|
|
<div class="th-section-header">
|
|
<h2 class="th-section-title">{{ t('systemAdmin.users.createTitle') }}</h2>
|
|
<p
|
|
v-if="successMessage"
|
|
class="form-message"
|
|
>
|
|
{{ successMessage }}
|
|
</p>
|
|
</div>
|
|
<form
|
|
class="form-grid"
|
|
@submit.prevent="createUser"
|
|
>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.username') }}</span>
|
|
<input
|
|
v-model="createForm.username"
|
|
type="text"
|
|
autocomplete="off"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.initialPassword') }}</span>
|
|
<input
|
|
v-model="createForm.initial_password"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.displayName') }}</span>
|
|
<input
|
|
v-model="createForm.display_name"
|
|
type="text"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.userStatus') }}</span>
|
|
<select v-model="createForm.user_status">
|
|
<option value="ACTIVE">{{ t('systemAdmin.status.ACTIVE') }}</option>
|
|
<option value="DISABLED">{{ t('systemAdmin.status.DISABLED') }}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.roles') }}</span>
|
|
<select
|
|
v-model="createForm.role_ids"
|
|
multiple
|
|
>
|
|
<option
|
|
v-for="role in availableRoles"
|
|
:key="role.id"
|
|
:value="role.id"
|
|
>
|
|
{{ role.role_name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.hotels') }}</span>
|
|
<select
|
|
v-model="createForm.hotel_ids"
|
|
multiple
|
|
>
|
|
<option
|
|
v-for="hotel in activeHotels"
|
|
:key="hotel.hotel_id"
|
|
:value="hotel.hotel_id"
|
|
>
|
|
{{ hotel.hotel_name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.defaultHotel') }}</span>
|
|
<select v-model="createForm.default_hotel_id">
|
|
<option
|
|
v-for="hotel in activeHotels"
|
|
:key="hotel.hotel_id"
|
|
:value="hotel.hotel_id"
|
|
>
|
|
{{ hotel.hotel_name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
class="primary-button"
|
|
:disabled="saving"
|
|
>
|
|
{{ t('systemAdmin.common.create') }}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
|
|
<div class="system-admin-grid">
|
|
<section class="th-section table-section">
|
|
<div class="th-section-header">
|
|
<div>
|
|
<h2 class="th-section-title">{{ t('systemAdmin.users.tableTitle') }}</h2>
|
|
<p class="th-muted">{{ t('workbench.totalPrefix') }} {{ total }} {{ t('systemAdmin.users.totalSuffix') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="loading"
|
|
class="empty-state"
|
|
>
|
|
{{ t('common.loading') }}
|
|
</div>
|
|
<div
|
|
v-else-if="errorMessage"
|
|
class="empty-state empty-state--error"
|
|
>
|
|
{{ errorMessage }}
|
|
</div>
|
|
<div
|
|
v-else-if="!users.length"
|
|
class="empty-state"
|
|
>
|
|
{{ t('common.noData') }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="table-wrap"
|
|
>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('systemAdmin.users.columns.user') }}</th>
|
|
<th>{{ t('systemAdmin.users.columns.status') }}</th>
|
|
<th>{{ t('systemAdmin.users.columns.roles') }}</th>
|
|
<th>{{ t('systemAdmin.users.columns.defaultHotel') }}</th>
|
|
<th>{{ t('workbench.columns.updatedAt') }}</th>
|
|
<th>{{ t('workbench.columns.operation') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
>
|
|
<td>
|
|
<strong>{{ user.display_name || user.username }}</strong>
|
|
<small class="th-code">{{ user.username }}</small>
|
|
</td>
|
|
<td><span class="status-pill">{{ statusLabel(user.user_status) }}</span></td>
|
|
<td>{{ roleSummary(user.roles) }}</td>
|
|
<td>{{ user.default_hotel_id || '-' }}</td>
|
|
<td>{{ formatReservationDateTime(user.updated_at) }}</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
class="row-action"
|
|
@click="selectUser(user.id)"
|
|
>
|
|
{{ t('systemAdmin.common.viewDetail') }}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<footer class="pagination-bar">
|
|
<label class="page-size-control">
|
|
<span>{{ t('common.pageSize') }}</span>
|
|
<select
|
|
v-model.number="filters.page_size"
|
|
@change="resetToFirstPage"
|
|
>
|
|
<option :value="10">10</option>
|
|
<option :value="20">20</option>
|
|
<option :value="50">50</option>
|
|
</select>
|
|
</label>
|
|
<div class="pagination-actions">
|
|
<button
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="currentPage <= 1 || loading"
|
|
@click="goToPage(currentPage - 1)"
|
|
>
|
|
{{ t('common.previousPage') }}
|
|
</button>
|
|
<span>{{ t('common.pageStatus', { current: currentPage, total: totalPages }) }}</span>
|
|
<button
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="currentPage >= totalPages || loading"
|
|
@click="goToPage(currentPage + 1)"
|
|
>
|
|
{{ t('common.nextPage') }}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</section>
|
|
|
|
<aside class="th-section detail-panel">
|
|
<div class="th-section-header">
|
|
<h2 class="th-section-title">{{ t('systemAdmin.users.detailTitle') }}</h2>
|
|
</div>
|
|
<div
|
|
v-if="detailLoading"
|
|
class="empty-state empty-state--compact"
|
|
>
|
|
{{ t('common.loading') }}
|
|
</div>
|
|
<div
|
|
v-else-if="selectedUser"
|
|
class="detail-list"
|
|
>
|
|
<div>
|
|
<span>{{ t('systemAdmin.users.columns.user') }}</span>
|
|
<strong>{{ selectedUser.display_name || selectedUser.username }}</strong>
|
|
<small class="th-code">{{ selectedUser.id }}</small>
|
|
</div>
|
|
<div>
|
|
<span>{{ t('systemAdmin.users.email') }}</span>
|
|
<strong>{{ selectedUser.email || '-' }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>{{ t('systemAdmin.users.phone') }}</span>
|
|
<strong>{{ selectedUser.phone || '-' }}</strong>
|
|
</div>
|
|
<div>
|
|
<span>{{ t('systemAdmin.users.hotels') }}</span>
|
|
<strong>{{ hotelSummary(selectedUser.hotels) }}</strong>
|
|
</div>
|
|
<form
|
|
class="stacked-form"
|
|
@submit.prevent="updateSelectedUser"
|
|
>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.displayName') }}</span>
|
|
<input
|
|
v-model="editForm.display_name"
|
|
type="text"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.email') }}</span>
|
|
<input
|
|
v-model="editForm.email"
|
|
type="email"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.phone') }}</span>
|
|
<input
|
|
v-model="editForm.phone"
|
|
type="text"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.userStatus') }}</span>
|
|
<select v-model="editForm.user_status">
|
|
<option value="ACTIVE">{{ t('systemAdmin.status.ACTIVE') }}</option>
|
|
<option value="DISABLED">{{ t('systemAdmin.status.DISABLED') }}</option>
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
class="secondary-button"
|
|
:disabled="saving"
|
|
>
|
|
{{ t('systemAdmin.common.save') }}
|
|
</button>
|
|
</form>
|
|
<form
|
|
class="stacked-form"
|
|
@submit.prevent="assignSelectedUserRoles"
|
|
>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.roles') }}</span>
|
|
<select
|
|
v-model="editRoleIds"
|
|
multiple
|
|
>
|
|
<option
|
|
v-for="role in availableRoles"
|
|
:key="role.id"
|
|
:value="role.id"
|
|
>
|
|
{{ role.role_name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
class="secondary-button"
|
|
:disabled="saving"
|
|
>
|
|
{{ t('systemAdmin.users.saveRoles') }}
|
|
</button>
|
|
</form>
|
|
<form
|
|
class="stacked-form"
|
|
@submit.prevent="assignSelectedUserHotels"
|
|
>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.hotels') }}</span>
|
|
<select
|
|
v-model="editHotelIds"
|
|
multiple
|
|
>
|
|
<option
|
|
v-for="hotel in activeHotels"
|
|
:key="hotel.hotel_id"
|
|
:value="hotel.hotel_id"
|
|
>
|
|
{{ hotel.hotel_name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('systemAdmin.users.defaultHotel') }}</span>
|
|
<select v-model="editDefaultHotelId">
|
|
<option
|
|
v-for="hotel in activeHotels"
|
|
:key="hotel.hotel_id"
|
|
:value="hotel.hotel_id"
|
|
>
|
|
{{ hotel.hotel_name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="submit"
|
|
class="secondary-button"
|
|
:disabled="saving"
|
|
>
|
|
{{ t('systemAdmin.users.saveHotels') }}
|
|
</button>
|
|
</form>
|
|
<button
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="saving"
|
|
@click="resetSelectedUserPassword"
|
|
>
|
|
{{ t('systemAdmin.users.resetPassword') }}
|
|
</button>
|
|
<p
|
|
v-if="temporaryPassword"
|
|
class="form-message"
|
|
>
|
|
{{ t('systemAdmin.users.temporaryPassword') }} {{ temporaryPassword }}
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="empty-state empty-state--compact"
|
|
>
|
|
{{ t('systemAdmin.common.selectRow') }}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import {
|
|
assignAdminUserHotels,
|
|
assignAdminUserRoles,
|
|
createAdminUser,
|
|
fetchAdminHotels,
|
|
fetchAdminRoles,
|
|
fetchAdminUserDetail,
|
|
fetchAdminUsers,
|
|
resetAdminUserPassword,
|
|
updateAdminUser,
|
|
} from '@/services/systemAdminService'
|
|
import type {
|
|
AdminHotelResult,
|
|
AdminRoleListItemResult,
|
|
AdminUserCreateRequest,
|
|
AdminUserDetailResult,
|
|
AdminUserHotelResult,
|
|
AdminUserListFilters,
|
|
AdminUserListItemResult,
|
|
AdminUserRoleResult,
|
|
PlatformPaginationResult,
|
|
} from '@/types/systemAdmin'
|
|
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
|
|
|
const { t } = useI18n()
|
|
const loading = ref(false)
|
|
const detailLoading = ref(false)
|
|
const errorMessage = ref('')
|
|
const successMessage = ref('')
|
|
const saving = ref(false)
|
|
const temporaryPassword = ref('')
|
|
const users = ref<AdminUserListItemResult[]>([])
|
|
const selectedUser = ref<AdminUserDetailResult | null>(null)
|
|
const availableRoles = ref<AdminRoleListItemResult[]>([])
|
|
const availableHotels = ref<AdminHotelResult[]>([])
|
|
const activeHotels = computed(() => availableHotels.value.filter((hotel) => hotel.hotel_status === 'ACTIVE'))
|
|
const page = ref(createPage(1, 20, 0))
|
|
const total = computed(() => page.value.total)
|
|
const currentPage = computed(() => page.value.page_num)
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(page.value.total / Math.max(page.value.page_size, 1))))
|
|
const filters = reactive<AdminUserListFilters>({
|
|
keyword: '',
|
|
user_status: '',
|
|
page_num: 1,
|
|
page_size: 20,
|
|
})
|
|
const createForm = reactive<AdminUserCreateRequest>({
|
|
username: '',
|
|
initial_password: '',
|
|
display_name: '',
|
|
email: '',
|
|
phone: '',
|
|
user_status: 'ACTIVE',
|
|
role_ids: [],
|
|
hotel_ids: [],
|
|
default_hotel_id: '',
|
|
})
|
|
const editForm = reactive({
|
|
display_name: '',
|
|
email: '',
|
|
phone: '',
|
|
user_status: 'ACTIVE',
|
|
})
|
|
const editRoleIds = ref<string[]>([])
|
|
const editHotelIds = ref<string[]>([])
|
|
const editDefaultHotelId = ref('')
|
|
let requestSequence = 0
|
|
let detailRequestSequence = 0
|
|
|
|
onMounted(() => {
|
|
void loadUsers()
|
|
void loadReferenceData()
|
|
})
|
|
|
|
watch(filters, () => {
|
|
void loadUsers()
|
|
})
|
|
|
|
async function loadUsers(): Promise<void> {
|
|
const currentRequest = ++requestSequence
|
|
loading.value = true
|
|
errorMessage.value = ''
|
|
try {
|
|
const result = await fetchAdminUsers({ ...filters })
|
|
if (currentRequest !== requestSequence) {
|
|
return
|
|
}
|
|
users.value = result.items
|
|
page.value = result.page
|
|
if (!selectedUser.value && result.items[0]) {
|
|
void selectUser(result.items[0].id)
|
|
}
|
|
} catch (error) {
|
|
if (currentRequest !== requestSequence) {
|
|
return
|
|
}
|
|
users.value = []
|
|
page.value = createPage(filters.page_num ?? 1, filters.page_size ?? 20, 0)
|
|
errorMessage.value = error instanceof Error ? error.message : String(error)
|
|
} finally {
|
|
if (currentRequest === requestSequence) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
async function selectUser(userId: string): Promise<void> {
|
|
const currentRequest = ++detailRequestSequence
|
|
detailLoading.value = true
|
|
try {
|
|
const result = await fetchAdminUserDetail(userId)
|
|
if (currentRequest === detailRequestSequence) {
|
|
selectedUser.value = result
|
|
populateEditForms(result)
|
|
temporaryPassword.value = ''
|
|
}
|
|
} finally {
|
|
if (currentRequest === detailRequestSequence) {
|
|
detailLoading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadReferenceData(): Promise<void> {
|
|
const [rolesResult, hotelsResult] = await Promise.all([
|
|
fetchAdminRoles({ page_num: 1, page_size: 100 }),
|
|
fetchAdminHotels({ page_num: 1, page_size: 100 }),
|
|
])
|
|
availableRoles.value = rolesResult.items
|
|
availableHotels.value = hotelsResult.items
|
|
if (!createForm.default_hotel_id && activeHotels.value[0]) {
|
|
createForm.hotel_ids = [activeHotels.value[0].hotel_id]
|
|
createForm.default_hotel_id = activeHotels.value[0].hotel_id
|
|
}
|
|
}
|
|
|
|
async function createUser(): Promise<void> {
|
|
saving.value = true
|
|
successMessage.value = ''
|
|
errorMessage.value = ''
|
|
try {
|
|
const result = await createAdminUser({ ...createForm })
|
|
successMessage.value = t('systemAdmin.users.created')
|
|
resetCreateForm()
|
|
await loadUsers()
|
|
await selectUser(result.id)
|
|
} catch (error) {
|
|
setOperationError(error)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function updateSelectedUser(): Promise<void> {
|
|
if (!selectedUser.value) {
|
|
return
|
|
}
|
|
saving.value = true
|
|
errorMessage.value = ''
|
|
try {
|
|
const result = await updateAdminUser(selectedUser.value.id, { ...editForm })
|
|
selectedUser.value = result
|
|
populateEditForms(result)
|
|
await loadUsers()
|
|
} catch (error) {
|
|
setOperationError(error)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function assignSelectedUserRoles(): Promise<void> {
|
|
if (!selectedUser.value) {
|
|
return
|
|
}
|
|
saving.value = true
|
|
errorMessage.value = ''
|
|
try {
|
|
const result = await assignAdminUserRoles(selectedUser.value.id, {
|
|
role_ids: editRoleIds.value,
|
|
})
|
|
selectedUser.value = result
|
|
populateEditForms(result)
|
|
await loadUsers()
|
|
} catch (error) {
|
|
setOperationError(error)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function assignSelectedUserHotels(): Promise<void> {
|
|
if (!selectedUser.value) {
|
|
return
|
|
}
|
|
saving.value = true
|
|
errorMessage.value = ''
|
|
try {
|
|
const result = await assignAdminUserHotels(selectedUser.value.id, {
|
|
hotel_ids: editHotelIds.value,
|
|
default_hotel_id: editDefaultHotelId.value,
|
|
})
|
|
selectedUser.value = result
|
|
populateEditForms(result)
|
|
await loadUsers()
|
|
} catch (error) {
|
|
setOperationError(error)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function resetSelectedUserPassword(): Promise<void> {
|
|
if (!selectedUser.value) {
|
|
return
|
|
}
|
|
saving.value = true
|
|
temporaryPassword.value = ''
|
|
errorMessage.value = ''
|
|
try {
|
|
const result = await resetAdminUserPassword(selectedUser.value.id)
|
|
temporaryPassword.value = result.temporary_password
|
|
} catch (error) {
|
|
setOperationError(error)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function resetFilters(): void {
|
|
filters.keyword = ''
|
|
filters.user_status = ''
|
|
filters.page_num = 1
|
|
filters.page_size = 20
|
|
}
|
|
|
|
function resetCreateForm(): void {
|
|
createForm.username = ''
|
|
createForm.initial_password = ''
|
|
createForm.display_name = ''
|
|
createForm.email = ''
|
|
createForm.phone = ''
|
|
createForm.user_status = 'ACTIVE'
|
|
createForm.role_ids = []
|
|
createForm.hotel_ids = activeHotels.value[0] ? [activeHotels.value[0].hotel_id] : []
|
|
createForm.default_hotel_id = activeHotels.value[0]?.hotel_id ?? ''
|
|
}
|
|
|
|
function populateEditForms(user: AdminUserDetailResult): void {
|
|
editForm.display_name = user.display_name || ''
|
|
editForm.email = user.email || ''
|
|
editForm.phone = user.phone || ''
|
|
editForm.user_status = user.user_status
|
|
editRoleIds.value = user.roles.map((role) => role.id)
|
|
editHotelIds.value = user.hotels.map((hotel) => hotel.hotel_id)
|
|
editDefaultHotelId.value = user.hotels.find((hotel) => hotel.default_hotel)?.hotel_id ?? editHotelIds.value[0] ?? ''
|
|
}
|
|
|
|
function resetToFirstPage(): void {
|
|
filters.page_num = 1
|
|
}
|
|
|
|
function goToPage(pageNum: number): void {
|
|
filters.page_num = Math.min(Math.max(pageNum, 1), totalPages.value)
|
|
}
|
|
|
|
function createPage(pageNum: number, pageSize: number, total: number): PlatformPaginationResult {
|
|
return {
|
|
page_num: pageNum,
|
|
page_size: pageSize,
|
|
total,
|
|
}
|
|
}
|
|
|
|
function roleSummary(roles: AdminUserRoleResult[]): string {
|
|
return roles.map((role) => role.role_name || role.role_code).join(', ') || '-'
|
|
}
|
|
|
|
function hotelSummary(hotels: AdminUserHotelResult[]): string {
|
|
return hotels.map((hotel) => hotel.hotel_name || hotel.hotel_id).join(', ') || '-'
|
|
}
|
|
|
|
function statusLabel(status: string): string {
|
|
return t(`systemAdmin.status.${status}`)
|
|
}
|
|
|
|
function setOperationError(error: unknown): void {
|
|
errorMessage.value = error instanceof Error ? error.message : String(error)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
@import './system-admin.css';
|
|
</style>
|