实现V4目录管理后台前端

This commit is contained in:
andy
2026-07-20 00:09:58 +07:00
parent 739fccd955
commit f40f90553f
19 changed files with 1654 additions and 11 deletions

View File

@@ -40,6 +40,7 @@ export default {
systemRoles: 'Roles',
systemMenus: 'Menus',
systemHotels: 'Hotels',
systemReservationCatalogs: 'Reservation catalogs',
systemAudits: 'Admin audits',
},
common: {
@@ -193,6 +194,51 @@ export default {
sort: 'Sort',
},
},
catalogs: {
title: 'Reservation V4 catalog management',
subtitle: 'Maintain Account, Room Type, and Rate Code catalogs for V4 task card lookup and backend confirmation validation.',
typeNavigation: 'Reservation V4 catalog type',
filters: 'Catalog filters',
hotel: 'Hotel',
defaultHotel: 'Current default hotel',
keywordPlaceholder: 'Code / name',
catalogStatus: 'Catalog status',
createTitle: 'Create {type}',
createHint: 'V1 catalogs are maintained by system admins and become active after saving.',
createAction: 'Create catalog',
createSucceeded: 'Catalog created',
statusUpdated: 'Catalog status updated',
disabledLookupNotice: 'Disabled catalog items no longer appear in ordinary V4 task card lookup; confirmed historical cards are not rolled back.',
defaultActiveHint: 'New items are active by default. Account V1 uses fixed Market=LEISURE and Source=TRAVEL_AGENT.',
hotelRequired: 'Select a hotel first.',
tableTitle: '{type} data',
totalSuffix: 'catalog items',
accountCode: 'Account Code',
accountName: 'Account name',
marketCode: 'Market Code',
sourceCode: 'Source Code',
externalAccountId: 'External Account ID',
code: 'Code',
displayName: 'Display name',
externalId: 'External catalog ID',
sortOrder: 'Sort order',
catalogVersion: 'Catalog version',
metadataJson: 'Metadata JSON',
enableAction: 'Enable',
disableAction: 'Disable',
types: {
accounts: 'Account',
roomTypes: 'Room Type',
rateCodes: 'Rate Code',
},
columns: {
catalog: 'Catalog',
status: 'Status',
marketSource: 'Market / Source',
source: 'Source / version',
sort: 'Sort',
},
},
audits: {
title: 'Audit filters',
tableTitle: 'Admin audit data',

View File

@@ -40,6 +40,7 @@ export default {
systemRoles: 'บทบาท',
systemMenus: 'เมนู',
systemHotels: 'โรงแรม',
systemReservationCatalogs: 'แคตตาล็อกการจอง',
systemAudits: 'บันทึกผู้ดูแล',
},
common: {
@@ -193,6 +194,51 @@ export default {
sort: 'ลำดับ',
},
},
catalogs: {
title: 'จัดการแคตตาล็อก Reservation V4',
subtitle: 'ดูแล Account, Room Type และ Rate Code สำหรับ lookup ในการ์ดงาน V4 และการตรวจสอบตอนยืนยันโดยหลังบ้าน',
typeNavigation: 'ประเภทแคตตาล็อก Reservation V4',
filters: 'ตัวกรองแคตตาล็อก',
hotel: 'โรงแรม',
defaultHotel: 'โรงแรมเริ่มต้นปัจจุบัน',
keywordPlaceholder: 'รหัส / ชื่อ',
catalogStatus: 'สถานะแคตตาล็อก',
createTitle: 'สร้าง {type}',
createHint: 'เวอร์ชันแรกดูแลแคตตาล็อกโดยผู้ดูแลระบบ และหลังบันทึกจะเปิดใช้งานโดยอัตโนมัติ',
createAction: 'สร้างแคตตาล็อก',
createSucceeded: 'สร้างแคตตาล็อกแล้ว',
statusUpdated: 'อัปเดตสถานะแคตตาล็อกแล้ว',
disabledLookupNotice: 'รายการที่ปิดใช้งานจะไม่ถูกส่งกลับใน lookup ของการ์ดงาน V4 ทั่วไป แต่การ์ดที่ยืนยันแล้วในอดีตจะไม่ถูกย้อนกลับ',
defaultActiveHint: 'รายการใหม่เปิดใช้งานโดยค่าเริ่มต้น Account เวอร์ชันแรกใช้ Market=LEISURE และ Source=TRAVEL_AGENT แบบคงที่',
hotelRequired: 'โปรดเลือกโรงแรมก่อน',
tableTitle: 'ข้อมูล {type}',
totalSuffix: 'รายการแคตตาล็อก',
accountCode: 'Account Code',
accountName: 'ชื่อ Account',
marketCode: 'Market Code',
sourceCode: 'Source Code',
externalAccountId: 'External Account ID',
code: 'รหัส',
displayName: 'ชื่อแสดง',
externalId: 'External Catalog ID',
sortOrder: 'ลำดับ',
catalogVersion: 'เวอร์ชันแคตตาล็อก',
metadataJson: 'Metadata JSON',
enableAction: 'เปิดใช้งาน',
disableAction: 'ปิดใช้งาน',
types: {
accounts: 'Account',
roomTypes: 'Room Type',
rateCodes: 'Rate Code',
},
columns: {
catalog: 'แคตตาล็อก',
status: 'สถานะ',
marketSource: 'Market / Source',
source: 'แหล่งที่มา / เวอร์ชัน',
sort: 'ลำดับ',
},
},
audits: {
title: 'ตัวกรองบันทึก',
tableTitle: 'ข้อมูลบันทึกผู้ดูแล',

View File

@@ -40,6 +40,7 @@ export default {
systemRoles: '角色权限',
systemMenus: '菜单管理',
systemHotels: '酒店管理',
systemReservationCatalogs: '预订目录',
systemAudits: '管理审计',
},
common: {
@@ -193,6 +194,51 @@ export default {
sort: '排序',
},
},
catalogs: {
title: 'Reservation V4 目录管理',
subtitle: '维护 Account、Room Type 和 Rate Code供 V4 任务卡 lookup 和后端确认校验使用。',
typeNavigation: 'Reservation V4 目录类型',
filters: '目录筛选',
hotel: '酒店',
defaultHotel: '当前默认酒店',
keywordPlaceholder: '代码 / 名称',
catalogStatus: '目录状态',
createTitle: '新增 {type}',
createHint: '第一版新增目录由系统管理维护,保存后默认启用。',
createAction: '新增目录',
createSucceeded: '目录已新增',
statusUpdated: '目录状态已更新',
disabledLookupNotice: '停用目录项后,普通 V4 任务卡 lookup 不再返回该项;已确认历史卡片不会被回滚。',
defaultActiveHint: '新增后默认启用。Account 第一版固定使用 Market=LEISURE、Source=TRAVEL_AGENT。',
hotelRequired: '请先选择酒店。',
tableTitle: '{type} 数据',
totalSuffix: '条目录',
accountCode: 'Account Code',
accountName: 'Account 名称',
marketCode: 'Market Code',
sourceCode: 'Source Code',
externalAccountId: '外部 Account ID',
code: '代码',
displayName: '显示名称',
externalId: '外部目录 ID',
sortOrder: '排序',
catalogVersion: '目录版本',
metadataJson: '扩展 JSON',
enableAction: '启用',
disableAction: '停用',
types: {
accounts: 'Account',
roomTypes: 'Room Type',
rateCodes: 'Rate Code',
},
columns: {
catalog: '目录',
status: '状态',
marketSource: 'Market / Source',
source: '来源 / 版本',
sort: '排序',
},
},
audits: {
title: '审计筛选',
tableTitle: '管理审计数据',

View File

@@ -179,6 +179,7 @@ const menuLabelKeys: Record<string, string> = {
RESERVATION_ROOMING_LISTS: 'nav.roomingList',
DEBUG_EML_SUPERAGENT: 'nav.debugEml',
SYSTEM_SETTINGS: 'nav.systemSettings',
SYSTEM_RESERVATION_CATALOGS: 'nav.systemReservationCatalogs',
}
const navItems = computed(() => authStore.visibleMenus)

View File

@@ -178,6 +178,15 @@ export const router = createRouter({
permission: 'HOTEL_MANAGE',
},
},
{
path: 'reservation-catalogs',
name: 'system-reservation-catalogs',
component: () => import('@/views/system/SystemReservationCatalogsView.vue'),
meta: {
titleKey: 'nav.systemReservationCatalogs',
permission: 'RESERVATION_CATALOG_MANAGE',
},
},
{
path: 'audits',
name: 'system-audits',
@@ -258,5 +267,8 @@ function resolveSystemAdminEntryPath(authStore: AuthStore): string {
if (authStore.hasPermission('HOTEL_MANAGE')) {
return '/system/hotels'
}
if (authStore.hasPermission('RESERVATION_CATALOG_MANAGE')) {
return '/system/reservation-catalogs'
}
return '/system/audits'
}

View File

@@ -14,6 +14,12 @@ import type {
AdminMenuTreeResult,
AdminMenuUpdateRequest,
AdminPermissionResult,
AdminReservationCatalogAccountCreateRequest,
AdminReservationCatalogAccountResult,
AdminReservationCatalogCodeCreateRequest,
AdminReservationCatalogCodeResult,
AdminReservationCatalogListFilters,
AdminReservationCatalogStatusUpdateRequest,
AdminRoleCreateRequest,
AdminRoleDetailResult,
AdminRoleListFilters,
@@ -144,6 +150,81 @@ export async function fetchAdminHotels(
return getJson<PlatformPageResult<AdminHotelResult>>(withQuery('/api/admin/hotels', filters))
}
export async function fetchAdminReservationCatalogAccounts(
filters: AdminReservationCatalogListFilters = {},
): Promise<PlatformPageResult<AdminReservationCatalogAccountResult>> {
return getJson<PlatformPageResult<AdminReservationCatalogAccountResult>>(
withQuery('/api/admin/reservation/catalogs/accounts', filters),
)
}
export async function createAdminReservationCatalogAccount(
request: AdminReservationCatalogAccountCreateRequest,
): Promise<AdminReservationCatalogAccountResult> {
return sendJson<AdminReservationCatalogAccountResult>('/api/admin/reservation/catalogs/accounts', 'POST', request)
}
export async function updateAdminReservationCatalogAccountStatus(
accountId: string,
request: AdminReservationCatalogStatusUpdateRequest,
): Promise<AdminReservationCatalogAccountResult> {
return sendJson<AdminReservationCatalogAccountResult>(
`/api/admin/reservation/catalogs/accounts/${encodeURIComponent(accountId)}/status`,
'PUT',
request,
)
}
export async function fetchAdminReservationCatalogRoomTypes(
filters: AdminReservationCatalogListFilters = {},
): Promise<PlatformPageResult<AdminReservationCatalogCodeResult>> {
return getJson<PlatformPageResult<AdminReservationCatalogCodeResult>>(
withQuery('/api/admin/reservation/catalogs/room-types', filters),
)
}
export async function createAdminReservationCatalogRoomType(
request: AdminReservationCatalogCodeCreateRequest,
): Promise<AdminReservationCatalogCodeResult> {
return sendJson<AdminReservationCatalogCodeResult>('/api/admin/reservation/catalogs/room-types', 'POST', request)
}
export async function updateAdminReservationCatalogRoomTypeStatus(
catalogId: string,
request: AdminReservationCatalogStatusUpdateRequest,
): Promise<AdminReservationCatalogCodeResult> {
return sendJson<AdminReservationCatalogCodeResult>(
`/api/admin/reservation/catalogs/room-types/${encodeURIComponent(catalogId)}/status`,
'PUT',
request,
)
}
export async function fetchAdminReservationCatalogRateCodes(
filters: AdminReservationCatalogListFilters = {},
): Promise<PlatformPageResult<AdminReservationCatalogCodeResult>> {
return getJson<PlatformPageResult<AdminReservationCatalogCodeResult>>(
withQuery('/api/admin/reservation/catalogs/rate-codes', filters),
)
}
export async function createAdminReservationCatalogRateCode(
request: AdminReservationCatalogCodeCreateRequest,
): Promise<AdminReservationCatalogCodeResult> {
return sendJson<AdminReservationCatalogCodeResult>('/api/admin/reservation/catalogs/rate-codes', 'POST', request)
}
export async function updateAdminReservationCatalogRateCodeStatus(
catalogId: string,
request: AdminReservationCatalogStatusUpdateRequest,
): Promise<AdminReservationCatalogCodeResult> {
return sendJson<AdminReservationCatalogCodeResult>(
`/api/admin/reservation/catalogs/rate-codes/${encodeURIComponent(catalogId)}/status`,
'PUT',
request,
)
}
export async function createAdminHotel(request: AdminHotelCreateRequest): Promise<AdminHotelResult> {
return sendJson<AdminHotelResult>('/api/admin/hotels', 'POST', request)
}

View File

@@ -60,6 +60,7 @@ describe('reservation router', () => {
expect(router.resolve('/system/roles').name).toBe('system-roles')
expect(router.resolve('/system/menus').name).toBe('system-menus')
expect(router.resolve('/system/hotels').name).toBe('system-hotels')
expect(router.resolve('/system/reservation-catalogs').name).toBe('system-reservation-catalogs')
expect(router.resolve('/system/audits').name).toBe('system-audits')
expect(router.resolve('/system/not-ready').name).toBe('not-found')
})
@@ -81,6 +82,7 @@ describe('reservation router', () => {
expect(router.resolve('/system/roles').meta.permission).toBe('SYSTEM_ROLE_MANAGE')
expect(router.resolve('/system/menus').meta.permission).toBe('SYSTEM_MENU_MANAGE')
expect(router.resolve('/system/hotels').meta.permission).toBe('HOTEL_MANAGE')
expect(router.resolve('/system/reservation-catalogs').meta.permission).toBe('RESERVATION_CATALOG_MANAGE')
expect(router.resolve('/system/audits').meta.permission).toBe('SYSTEM_ADMIN_CONSOLE_ACCESS')
})
@@ -152,6 +154,19 @@ describe('reservation router', () => {
)
})
it('redirects the System Admin entry to catalog management when it is the first allowed admin page', async () => {
const guard = createAuthGuard(() =>
createAuthStoreForGuard({
hasPermission: (permission: string) =>
permission === 'SYSTEM_ADMIN_CONSOLE_ACCESS' || permission === 'RESERVATION_CATALOG_MANAGE',
}),
)
await expect(guard(createRoute('/system', { permission: 'SYSTEM_ADMIN_CONSOLE_ACCESS' }))).resolves.toBe(
'/system/reservation-catalogs',
)
})
it('redirects the root route to the default page when the backend menu points to root', async () => {
const guard = createAuthGuard(() =>
createAuthStoreForGuard({

View File

@@ -7,12 +7,18 @@ import {
createAdminHotel,
createAdminMenu,
createAdminRole,
createAdminReservationCatalogAccount,
createAdminReservationCatalogRateCode,
createAdminReservationCatalogRoomType,
createAdminUser,
fetchAdminAudits,
fetchAdminHotels,
fetchAdminMenuTree,
fetchAdminMenus,
fetchAdminPermissions,
fetchAdminReservationCatalogAccounts,
fetchAdminReservationCatalogRateCodes,
fetchAdminReservationCatalogRoomTypes,
fetchAdminRoleDetail,
fetchAdminRoles,
fetchAdminUserDetail,
@@ -22,6 +28,9 @@ import {
updateAdminHotelStatus,
updateAdminMenu,
updateAdminMenuTreeOrder,
updateAdminReservationCatalogAccountStatus,
updateAdminReservationCatalogRateCodeStatus,
updateAdminReservationCatalogRoomTypeStatus,
updateAdminRole,
updateAdminUser,
} from '@/services/systemAdminService'
@@ -300,4 +309,109 @@ describe('systemAdminService', () => {
expect.objectContaining({ method: 'GET' }),
)
})
it('fetches Reservation V4 catalog management lists with supported filters', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ items: [], page: {} }))
await fetchAdminReservationCatalogAccounts({
hotel_id: 'HOTEL-TEST',
keyword: 'qbd',
status: 'ACTIVE',
page_num: 2,
page_size: 10,
})
await fetchAdminReservationCatalogRoomTypes({ hotel_id: 'HOTEL-TEST', status: 'DISABLED' })
await fetchAdminReservationCatalogRateCodes({ keyword: 'group' })
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/admin/reservation/catalogs/accounts?hotel_id=HOTEL-TEST&keyword=qbd&status=ACTIVE&page_num=2&page_size=10',
expect.objectContaining({ method: 'GET' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/admin/reservation/catalogs/room-types?hotel_id=HOTEL-TEST&status=DISABLED',
expect.objectContaining({ method: 'GET' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/api/admin/reservation/catalogs/rate-codes?keyword=group',
expect.objectContaining({ method: 'GET' }),
)
})
it('sends Reservation V4 catalog create and status operations to admin endpoints', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ id: '1' }))
await createAdminReservationCatalogAccount({
hotel_id: 'HOTEL-TEST',
account_code: 'ACME',
account_name: 'Acme Travel',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
})
await updateAdminReservationCatalogAccountStatus('10001', { status: 'DISABLED' })
await createAdminReservationCatalogRoomType({
hotel_id: 'HOTEL-TEST',
code: 'FAM',
display_name: 'Family Room',
sort_order: 300,
})
await updateAdminReservationCatalogRoomTypeStatus('20001', { status: 'ACTIVE' })
await createAdminReservationCatalogRateCode({
hotel_id: 'HOTEL-TEST',
code: 'CORP',
display_name: 'Corporate',
})
await updateAdminReservationCatalogRateCodeStatus('30001', { status: 'DISABLED' })
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/admin/reservation/catalogs/accounts',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
hotel_id: 'HOTEL-TEST',
account_code: 'ACME',
account_name: 'Acme Travel',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
}),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/admin/reservation/catalogs/accounts/10001/status',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ status: 'DISABLED' }),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/api/admin/reservation/catalogs/room-types',
expect.objectContaining({ method: 'POST' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'/api/admin/reservation/catalogs/room-types/20001/status',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ status: 'ACTIVE' }),
}),
)
expect(fetchMock).toHaveBeenNthCalledWith(
5,
'/api/admin/reservation/catalogs/rate-codes',
expect.objectContaining({ method: 'POST' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
6,
'/api/admin/reservation/catalogs/rate-codes/30001/status',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ status: 'DISABLED' }),
}),
)
})
})

View File

@@ -0,0 +1,274 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import zhCN from '@/i18n/locales/zh-CN'
import { useAuthStore } from '@/stores/authStore'
import SystemReservationCatalogsView from '@/views/system/SystemReservationCatalogsView.vue'
vi.mock('@/services/systemAdminService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/systemAdminService')>()
return {
...actual,
createAdminReservationCatalogAccount: vi.fn(),
createAdminReservationCatalogRateCode: vi.fn(),
createAdminReservationCatalogRoomType: vi.fn(),
fetchAdminReservationCatalogAccounts: vi.fn(),
fetchAdminReservationCatalogRateCodes: vi.fn(),
fetchAdminReservationCatalogRoomTypes: vi.fn(),
updateAdminReservationCatalogAccountStatus: vi.fn(),
updateAdminReservationCatalogRateCodeStatus: vi.fn(),
updateAdminReservationCatalogRoomTypeStatus: vi.fn(),
}
})
const systemAdminService = await import('@/services/systemAdminService')
describe('SystemReservationCatalogsView', () => {
beforeEach(() => {
vi.mocked(systemAdminService.createAdminReservationCatalogAccount).mockReset()
vi.mocked(systemAdminService.createAdminReservationCatalogRateCode).mockReset()
vi.mocked(systemAdminService.createAdminReservationCatalogRoomType).mockReset()
vi.mocked(systemAdminService.fetchAdminReservationCatalogAccounts).mockReset()
vi.mocked(systemAdminService.fetchAdminReservationCatalogRateCodes).mockReset()
vi.mocked(systemAdminService.fetchAdminReservationCatalogRoomTypes).mockReset()
vi.mocked(systemAdminService.updateAdminReservationCatalogAccountStatus).mockReset()
vi.mocked(systemAdminService.updateAdminReservationCatalogRateCodeStatus).mockReset()
vi.mocked(systemAdminService.updateAdminReservationCatalogRoomTypeStatus).mockReset()
vi.mocked(systemAdminService.fetchAdminReservationCatalogAccounts).mockResolvedValue({
items: [
{
id: '10001',
hotel_id: 'HOTEL-TEST',
account_code: 'QBD_TRAVEL',
account_name: 'Q.B.D. Travel',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
external_account_id: null,
catalog_version: 'catalog-v1',
metadata_json: null,
version: 1,
created_at: '2026-07-19T02:00:00Z',
updated_at: '2026-07-19T02:00:00Z',
},
],
page: {
page_num: 1,
page_size: 20,
total: 1,
},
})
vi.mocked(systemAdminService.fetchAdminReservationCatalogRoomTypes).mockResolvedValue({
items: [
{
id: '20001',
hotel_id: 'HOTEL-TEST',
catalog_type: 'ROOM_TYPE',
code: 'TWN',
display_name: 'Twin',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
external_id: null,
sort_order: 100,
catalog_version: 'catalog-v1',
metadata_json: null,
version: 1,
created_at: '2026-07-19T02:00:00Z',
updated_at: '2026-07-19T02:00:00Z',
},
],
page: {
page_num: 1,
page_size: 20,
total: 1,
},
})
vi.mocked(systemAdminService.fetchAdminReservationCatalogRateCodes).mockResolvedValue({
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
})
})
it('loads Account catalog for the selected hotel and creates with fixed Market and Source defaults', async () => {
vi.mocked(systemAdminService.createAdminReservationCatalogAccount).mockResolvedValue({
id: '10002',
hotel_id: 'HOTEL-TEST',
account_code: 'ACME',
account_name: 'Acme Travel',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
external_account_id: null,
catalog_version: 'catalog-v1',
metadata_json: null,
version: 1,
created_at: '2026-07-19T02:00:00Z',
updated_at: '2026-07-19T02:00:00Z',
})
const wrapper = mountCatalogView()
await flushPromises()
expect(wrapper.text()).toContain('Reservation V4 目录管理')
expect(wrapper.text()).toContain('QBD_TRAVEL')
expect(systemAdminService.fetchAdminReservationCatalogAccounts).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
keyword: '',
status: '',
page_num: 1,
page_size: 20,
})
await wrapper.find('[data-testid="catalog-account-code"]').setValue('ACME')
await wrapper.find('[data-testid="catalog-account-name"]').setValue('Acme Travel')
await wrapper.find('[data-testid="catalog-create-form"]').trigger('submit')
await flushPromises()
expect(systemAdminService.createAdminReservationCatalogAccount).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
account_code: 'ACME',
account_name: 'Acme Travel',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
external_account_id: undefined,
catalog_version: undefined,
metadata_json: undefined,
})
expect(wrapper.text()).toContain('新增后默认启用')
})
it('switches to Room Type catalog and toggles status by catalog id', async () => {
vi.mocked(systemAdminService.updateAdminReservationCatalogRoomTypeStatus).mockResolvedValue({
id: '20001',
hotel_id: 'HOTEL-TEST',
catalog_type: 'ROOM_TYPE',
code: 'TWN',
display_name: 'Twin',
status: 'DISABLED',
catalog_source: 'SYSTEM_MANAGED',
external_id: null,
sort_order: 100,
catalog_version: 'catalog-v1',
metadata_json: null,
version: 2,
created_at: '2026-07-19T02:00:00Z',
updated_at: '2026-07-19T03:00:00Z',
})
const wrapper = mountCatalogView()
await flushPromises()
await wrapper.find('[data-testid="catalog-tab-room-types"]').trigger('click')
await flushPromises()
expect(systemAdminService.fetchAdminReservationCatalogRoomTypes).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
keyword: '',
status: '',
page_num: 1,
page_size: 20,
})
expect(wrapper.text()).toContain('TWN')
await wrapper.find('[data-testid="catalog-status-20001"]').trigger('click')
await flushPromises()
expect(systemAdminService.updateAdminReservationCatalogRoomTypeStatus).toHaveBeenCalledWith('20001', {
status: 'DISABLED',
})
})
it('switches to Rate Code catalog and creates through the dedicated endpoint', async () => {
vi.mocked(systemAdminService.createAdminReservationCatalogRateCode).mockResolvedValue({
id: '30001',
hotel_id: 'HOTEL-TEST',
catalog_type: 'RATE_CODE',
code: 'CORP',
display_name: 'Corporate',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
external_id: null,
sort_order: 100,
catalog_version: 'catalog-v1',
metadata_json: null,
version: 1,
created_at: '2026-07-19T02:00:00Z',
updated_at: '2026-07-19T02:00:00Z',
})
const wrapper = mountCatalogView()
await flushPromises()
await wrapper.find('[data-testid="catalog-tab-rate-codes"]').trigger('click')
await flushPromises()
await wrapper.find('[data-testid="catalog-code"]').setValue('CORP')
await wrapper.find('[data-testid="catalog-display-name"]').setValue('Corporate')
await wrapper.find('[data-testid="catalog-create-form"]').trigger('submit')
await flushPromises()
expect(systemAdminService.fetchAdminReservationCatalogRateCodes).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
keyword: '',
status: '',
page_num: 1,
page_size: 20,
})
expect(systemAdminService.createAdminReservationCatalogRateCode).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
code: 'CORP',
display_name: 'Corporate',
external_id: undefined,
sort_order: 100,
catalog_version: undefined,
metadata_json: undefined,
})
})
})
function mountCatalogView() {
const pinia = createPinia()
setActivePinia(pinia)
const authStore = useAuthStore()
authStore.applyLoginResult({
access_token: 'test-token',
token_type: 'Bearer',
expires_at: '2026-07-19T12:00:00Z',
user: {
id: 'u1',
username: 'admin',
display_name: '系统管理员',
super_admin: false,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
],
permissions: ['SYSTEM_ADMIN_CONSOLE_ACCESS', 'RESERVATION_CATALOG_MANAGE'],
menus: [],
})
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
return mount(SystemReservationCatalogsView, {
global: {
plugins: [pinia, i18n],
},
})
}

View File

@@ -9,6 +9,7 @@ export type AuthPermissionCode =
| 'RESERVATION_AUDIT_READ'
| 'RESERVATION_INVOICE_GENERATE'
| 'RESERVATION_ROOMING_LIST_GENERATE'
| 'RESERVATION_CATALOG_MANAGE'
| 'HOTEL_SWITCH'
| 'SYSTEM_AUTH_READ'
| 'SYSTEM_ADMIN_CONSOLE_ACCESS'

View File

@@ -31,6 +31,11 @@ export interface AdminHotelListFilters extends SystemAdminPageFilters {
hotel_status?: string
}
export interface AdminReservationCatalogListFilters extends SystemAdminPageFilters {
hotel_id?: string
status?: string
}
export interface AdminUserRoleResult {
id: string
role_code: string
@@ -128,6 +133,40 @@ export interface AdminHotelResult {
updated_at: string | null
}
export interface AdminReservationCatalogAccountResult {
id: string
hotel_id: string
account_code: string
account_name: string
market_code: string
source_code: string
status: string
catalog_source: string
external_account_id: string | null
catalog_version: string | null
metadata_json: string | null
version: number | null
created_at: string | null
updated_at: string | null
}
export interface AdminReservationCatalogCodeResult {
id: string
hotel_id: string
catalog_type: string
code: string
display_name: string
status: string
catalog_source: string
external_id: string | null
sort_order: number | null
catalog_version: string | null
metadata_json: string | null
version: number | null
created_at: string | null
updated_at: string | null
}
export interface AdminUserCreateRequest {
username: string
initial_password: string
@@ -235,6 +274,31 @@ export interface AdminHotelStatusUpdateRequest {
hotel_status: string
}
export interface AdminReservationCatalogAccountCreateRequest {
hotel_id: string
account_code: string
account_name: string
market_code: string
source_code: string
external_account_id?: string
catalog_version?: string
metadata_json?: string
}
export interface AdminReservationCatalogCodeCreateRequest {
hotel_id: string
code: string
display_name: string
external_id?: string
sort_order?: number
catalog_version?: string
metadata_json?: string
}
export interface AdminReservationCatalogStatusUpdateRequest {
status: string
}
export interface AdminAuditLogListFilters extends SystemAdminPageFilters {
target_type?: string
target_id?: string

View File

@@ -70,6 +70,12 @@ const allTabs: Array<{
icon: 'pi pi-building',
permission: 'HOTEL_MANAGE',
},
{
path: '/system/reservation-catalogs',
labelKey: 'nav.systemReservationCatalogs',
icon: 'pi pi-list-check',
permission: 'RESERVATION_CATALOG_MANAGE',
},
{
path: '/system/audits',
labelKey: 'nav.systemAudits',

View File

@@ -0,0 +1,808 @@
<template>
<section class="system-admin-panel reservation-catalog-admin">
<section class="th-section catalog-intro">
<div class="th-section-header">
<div>
<h2 class="th-section-title">
{{ t('systemAdmin.catalogs.title') }}
</h2>
<p class="th-muted">
{{ t('systemAdmin.catalogs.subtitle') }}
</p>
</div>
<button
type="button"
class="secondary-button"
:disabled="loading"
@click="loadCatalog"
>
{{ t('common.refresh') }}
</button>
</div>
<p class="form-message">
{{ t('systemAdmin.catalogs.disabledLookupNotice') }}
</p>
</section>
<nav
class="catalog-type-tabs"
:aria-label="t('systemAdmin.catalogs.typeNavigation')"
>
<button
v-for="item in catalogTypes"
:key="item.kind"
type="button"
class="catalog-type-tabs__item"
:class="{ 'catalog-type-tabs__item--active': activeCatalog === item.kind }"
:data-testid="`catalog-tab-${item.kind}`"
@click="selectCatalog(item.kind)"
>
<i
:class="item.icon"
aria-hidden="true"
/>
<span>{{ t(item.labelKey) }}</span>
</button>
</nav>
<div class="th-section filter-section">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('systemAdmin.catalogs.filters') }}
</h2>
<button
type="button"
class="text-button"
@click="resetFilters"
>
{{ t('workbench.reset') }}
</button>
</div>
<div class="filter-grid">
<label>
<span>{{ t('systemAdmin.catalogs.hotel') }}</span>
<select
v-model="filters.hotel_id"
@change="resetToFirstPage"
>
<option value="">
{{ t('systemAdmin.catalogs.defaultHotel') }}
</option>
<option
v-for="hotel in authStore.hotels"
:key="hotel.hotel_id"
:value="hotel.hotel_id"
>
{{ hotel.hotel_name }} ({{ hotel.hotel_id }})
</option>
</select>
</label>
<label>
<span>{{ t('systemAdmin.common.keyword') }}</span>
<input
v-model="filters.keyword"
type="search"
:placeholder="t('systemAdmin.catalogs.keywordPlaceholder')"
@input="resetToFirstPage"
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.catalogStatus') }}</span>
<select
v-model="filters.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">
<div>
<h2 class="th-section-title">
{{ t('systemAdmin.catalogs.createTitle', { type: activeCatalogLabel }) }}
</h2>
<p class="th-muted">
{{ t('systemAdmin.catalogs.createHint') }}
</p>
</div>
</div>
<form
data-testid="catalog-create-form"
class="form-grid catalog-create-form"
@submit.prevent="createCatalog"
>
<template v-if="activeCatalog === 'accounts'">
<label>
<span>{{ t('systemAdmin.catalogs.accountCode') }}</span>
<input
v-model="accountForm.account_code"
data-testid="catalog-account-code"
type="text"
autocomplete="off"
required
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.accountName') }}</span>
<input
v-model="accountForm.account_name"
data-testid="catalog-account-name"
type="text"
autocomplete="off"
required
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.marketCode') }}</span>
<input
:value="fixedMarketCode"
type="text"
readonly
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.sourceCode') }}</span>
<input
:value="fixedSourceCode"
type="text"
readonly
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.externalAccountId') }}</span>
<input
v-model="accountForm.external_account_id"
type="text"
autocomplete="off"
>
</label>
</template>
<template v-else>
<label>
<span>{{ t('systemAdmin.catalogs.code') }}</span>
<input
v-model="codeForm.code"
data-testid="catalog-code"
type="text"
autocomplete="off"
required
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.displayName') }}</span>
<input
v-model="codeForm.display_name"
data-testid="catalog-display-name"
type="text"
autocomplete="off"
required
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.externalId') }}</span>
<input
v-model="codeForm.external_id"
type="text"
autocomplete="off"
>
</label>
<label>
<span>{{ t('systemAdmin.catalogs.sortOrder') }}</span>
<input
v-model.number="codeForm.sort_order"
type="number"
>
</label>
</template>
<label>
<span>{{ t('systemAdmin.catalogs.catalogVersion') }}</span>
<input
v-if="activeCatalog === 'accounts'"
v-model="accountForm.catalog_version"
type="text"
autocomplete="off"
>
<input
v-else
v-model="codeForm.catalog_version"
type="text"
autocomplete="off"
>
</label>
<label class="catalog-metadata-field">
<span>{{ t('systemAdmin.catalogs.metadataJson') }}</span>
<input
v-if="activeCatalog === 'accounts'"
v-model="accountForm.metadata_json"
type="text"
autocomplete="off"
>
<input
v-else
v-model="codeForm.metadata_json"
type="text"
autocomplete="off"
>
</label>
<button
type="submit"
class="primary-button"
:disabled="saving"
>
{{ saving ? t('common.saving') : t('systemAdmin.catalogs.createAction') }}
</button>
</form>
<p class="form-message">
{{ t('systemAdmin.catalogs.defaultActiveHint') }}
</p>
</section>
<section class="th-section table-section">
<div class="th-section-header">
<div>
<h2 class="th-section-title">
{{ t('systemAdmin.catalogs.tableTitle', { type: activeCatalogLabel }) }}
</h2>
<p class="th-muted">
{{ t('workbench.totalPrefix') }} {{ total }} {{ t('systemAdmin.catalogs.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="!currentItems.length"
class="empty-state"
>
{{ t('common.noData') }}
</div>
<div
v-else
class="table-wrap"
>
<table v-if="activeCatalog === 'accounts'">
<thead>
<tr>
<th>{{ t('systemAdmin.catalogs.columns.catalog') }}</th>
<th>{{ t('systemAdmin.catalogs.columns.status') }}</th>
<th>{{ t('systemAdmin.catalogs.columns.marketSource') }}</th>
<th>{{ t('systemAdmin.catalogs.columns.source') }}</th>
<th>{{ t('workbench.columns.updatedAt') }}</th>
<th>{{ t('workbench.columns.operation') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="account in accountItems"
:key="account.id"
>
<td>
<strong>{{ account.account_code }}</strong>
<small class="th-code">{{ account.account_name }}</small>
</td>
<td>
<span
class="status-pill"
:class="{ 'status-pill--active': account.status === 'ACTIVE' }"
>
{{ statusLabel(account.status) }}
</span>
</td>
<td>
<strong>{{ account.market_code }}</strong>
<small class="th-code">{{ account.source_code }}</small>
</td>
<td>
<strong>{{ account.catalog_source }}</strong>
<small class="th-code">{{ account.catalog_version ?? '-' }}</small>
</td>
<td>{{ formatReservationDateTime(account.updated_at) }}</td>
<td>
<button
type="button"
class="row-action"
:data-testid="`catalog-status-${account.id}`"
:disabled="saving"
@click="toggleCatalogStatus(account)"
>
{{ statusActionLabel(account.status) }}
</button>
</td>
</tr>
</tbody>
</table>
<table v-else>
<thead>
<tr>
<th>{{ t('systemAdmin.catalogs.columns.catalog') }}</th>
<th>{{ t('systemAdmin.catalogs.columns.status') }}</th>
<th>{{ t('systemAdmin.catalogs.columns.source') }}</th>
<th>{{ t('systemAdmin.catalogs.columns.sort') }}</th>
<th>{{ t('workbench.columns.updatedAt') }}</th>
<th>{{ t('workbench.columns.operation') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="code in codeItems"
:key="code.id"
>
<td>
<strong>{{ code.code }}</strong>
<small class="th-code">{{ code.display_name }}</small>
</td>
<td>
<span
class="status-pill"
:class="{ 'status-pill--active': code.status === 'ACTIVE' }"
>
{{ statusLabel(code.status) }}
</span>
</td>
<td>
<strong>{{ code.catalog_source }}</strong>
<small class="th-code">{{ code.catalog_version ?? '-' }}</small>
</td>
<td>{{ code.sort_order ?? '-' }}</td>
<td>{{ formatReservationDateTime(code.updated_at) }}</td>
<td>
<button
type="button"
class="row-action"
:data-testid="`catalog-status-${code.id}`"
:disabled="saving"
@click="toggleCatalogStatus(code)"
>
{{ statusActionLabel(code.status) }}
</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>
<p
v-if="successMessage"
class="action-message action-message--success"
>
{{ successMessage }}
</p>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ApiError } from '@/services/httpClient'
import {
createAdminReservationCatalogAccount,
createAdminReservationCatalogRateCode,
createAdminReservationCatalogRoomType,
fetchAdminReservationCatalogAccounts,
fetchAdminReservationCatalogRateCodes,
fetchAdminReservationCatalogRoomTypes,
updateAdminReservationCatalogAccountStatus,
updateAdminReservationCatalogRateCodeStatus,
updateAdminReservationCatalogRoomTypeStatus,
} from '@/services/systemAdminService'
import { useAuthStore } from '@/stores/authStore'
import type {
AdminReservationCatalogAccountCreateRequest,
AdminReservationCatalogAccountResult,
AdminReservationCatalogCodeCreateRequest,
AdminReservationCatalogCodeResult,
AdminReservationCatalogListFilters,
PlatformPaginationResult,
} from '@/types/systemAdmin'
import { formatReservationDateTime } from '@/utils/reservationFormat'
type CatalogKind = 'accounts' | 'room-types' | 'rate-codes'
type CatalogRecord = AdminReservationCatalogAccountResult | AdminReservationCatalogCodeResult
const fixedMarketCode = 'LEISURE'
const fixedSourceCode = 'TRAVEL_AGENT'
const catalogTypes: Array<{ kind: CatalogKind; labelKey: string; icon: string }> = [
{
kind: 'accounts',
labelKey: 'systemAdmin.catalogs.types.accounts',
icon: 'pi pi-briefcase',
},
{
kind: 'room-types',
labelKey: 'systemAdmin.catalogs.types.roomTypes',
icon: 'pi pi-home',
},
{
kind: 'rate-codes',
labelKey: 'systemAdmin.catalogs.types.rateCodes',
icon: 'pi pi-tags',
},
]
const { t } = useI18n()
const authStore = useAuthStore()
const activeCatalog = ref<CatalogKind>('accounts')
const loading = ref(false)
const saving = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const accountItems = ref<AdminReservationCatalogAccountResult[]>([])
const codeItems = ref<AdminReservationCatalogCodeResult[]>([])
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 currentItems = computed<CatalogRecord[]>(() =>
activeCatalog.value === 'accounts' ? accountItems.value : codeItems.value,
)
const activeCatalogLabel = computed(() => {
const activeType = catalogTypes.find((item) => item.kind === activeCatalog.value) ?? catalogTypes[0]!
return t(activeType.labelKey)
})
const filters = reactive<AdminReservationCatalogListFilters>({
hotel_id: authStore.selectedHotelId ?? '',
keyword: '',
status: '',
page_num: 1,
page_size: 20,
})
const accountForm = reactive({
account_code: '',
account_name: '',
external_account_id: '',
catalog_version: '',
metadata_json: '',
})
const codeForm = reactive({
code: '',
display_name: '',
external_id: '',
sort_order: 100,
catalog_version: '',
metadata_json: '',
})
let requestSequence = 0
onMounted(() => {
void loadCatalog()
})
watch(filters, () => {
void loadCatalog()
})
watch(activeCatalog, () => {
resetForms()
successMessage.value = ''
if ((filters.page_num ?? 1) === 1) {
void loadCatalog()
return
}
filters.page_num = 1
})
watch(
() => authStore.selectedHotelId,
(hotelId) => {
if (!filters.hotel_id && hotelId) {
filters.hotel_id = hotelId
}
},
)
async function loadCatalog(): Promise<void> {
const currentRequest = ++requestSequence
loading.value = true
errorMessage.value = ''
try {
const query = { ...filters }
const result = activeCatalog.value === 'accounts'
? await fetchAdminReservationCatalogAccounts(query)
: activeCatalog.value === 'room-types'
? await fetchAdminReservationCatalogRoomTypes(query)
: await fetchAdminReservationCatalogRateCodes(query)
if (currentRequest !== requestSequence) {
return
}
if (activeCatalog.value === 'accounts') {
accountItems.value = result.items as AdminReservationCatalogAccountResult[]
codeItems.value = []
} else {
accountItems.value = []
codeItems.value = result.items as AdminReservationCatalogCodeResult[]
}
page.value = result.page
} catch (error) {
if (currentRequest !== requestSequence) {
return
}
accountItems.value = []
codeItems.value = []
page.value = createPage(filters.page_num ?? 1, filters.page_size ?? 20, 0)
errorMessage.value = formatOperationError(error)
} finally {
if (currentRequest === requestSequence) {
loading.value = false
}
}
}
async function createCatalog(): Promise<void> {
const hotelId = activeHotelId()
if (!hotelId) {
errorMessage.value = t('systemAdmin.catalogs.hotelRequired')
return
}
saving.value = true
errorMessage.value = ''
successMessage.value = ''
try {
if (activeCatalog.value === 'accounts') {
await createAdminReservationCatalogAccount(buildAccountCreateRequest(hotelId))
} else if (activeCatalog.value === 'room-types') {
await createAdminReservationCatalogRoomType(buildCodeCreateRequest(hotelId))
} else {
await createAdminReservationCatalogRateCode(buildCodeCreateRequest(hotelId))
}
successMessage.value = t('systemAdmin.catalogs.createSucceeded')
resetForms()
await loadCatalog()
} catch (error) {
errorMessage.value = formatOperationError(error)
} finally {
saving.value = false
}
}
async function toggleCatalogStatus(record: CatalogRecord): Promise<void> {
const nextStatus = record.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
saving.value = true
errorMessage.value = ''
successMessage.value = ''
try {
if (activeCatalog.value === 'accounts') {
await updateAdminReservationCatalogAccountStatus(record.id, { status: nextStatus })
} else if (activeCatalog.value === 'room-types') {
await updateAdminReservationCatalogRoomTypeStatus(record.id, { status: nextStatus })
} else {
await updateAdminReservationCatalogRateCodeStatus(record.id, { status: nextStatus })
}
successMessage.value = t('systemAdmin.catalogs.statusUpdated')
await loadCatalog()
} catch (error) {
errorMessage.value = formatOperationError(error)
} finally {
saving.value = false
}
}
function selectCatalog(kind: CatalogKind): void {
if (activeCatalog.value !== kind) {
activeCatalog.value = kind
}
}
function resetFilters(): void {
filters.hotel_id = authStore.selectedHotelId ?? ''
filters.keyword = ''
filters.status = ''
filters.page_num = 1
filters.page_size = 20
}
function resetToFirstPage(): void {
filters.page_num = 1
}
function goToPage(pageNum: number): void {
filters.page_num = Math.min(Math.max(pageNum, 1), totalPages.value)
}
function resetForms(): void {
accountForm.account_code = ''
accountForm.account_name = ''
accountForm.external_account_id = ''
accountForm.catalog_version = ''
accountForm.metadata_json = ''
codeForm.code = ''
codeForm.display_name = ''
codeForm.external_id = ''
codeForm.sort_order = 100
codeForm.catalog_version = ''
codeForm.metadata_json = ''
}
function activeHotelId(): string {
return filters.hotel_id || authStore.selectedHotelId || ''
}
function buildAccountCreateRequest(hotelId: string): AdminReservationCatalogAccountCreateRequest {
return {
hotel_id: hotelId,
account_code: accountForm.account_code.trim(),
account_name: accountForm.account_name.trim(),
market_code: fixedMarketCode,
source_code: fixedSourceCode,
external_account_id: optionalText(accountForm.external_account_id),
catalog_version: optionalText(accountForm.catalog_version),
metadata_json: optionalText(accountForm.metadata_json),
}
}
function buildCodeCreateRequest(hotelId: string): AdminReservationCatalogCodeCreateRequest {
return {
hotel_id: hotelId,
code: codeForm.code.trim(),
display_name: codeForm.display_name.trim(),
external_id: optionalText(codeForm.external_id),
sort_order: Number.isFinite(codeForm.sort_order) ? codeForm.sort_order : undefined,
catalog_version: optionalText(codeForm.catalog_version),
metadata_json: optionalText(codeForm.metadata_json),
}
}
function optionalText(value: string): string | undefined {
const trimmed = value.trim()
return trimmed || undefined
}
function statusLabel(status: string): string {
return t(`systemAdmin.status.${status}`)
}
function statusActionLabel(status: string): string {
return status === 'ACTIVE' ? t('systemAdmin.catalogs.disableAction') : t('systemAdmin.catalogs.enableAction')
}
function createPage(pageNum: number, pageSize: number, total: number): PlatformPaginationResult {
return {
page_num: pageNum,
page_size: pageSize,
total,
}
}
function formatOperationError(error: unknown): string {
if (error instanceof ApiError) {
const details = error.details
if (details && typeof details === 'object' && !Array.isArray(details)) {
const record = details as { message?: unknown; error_code?: unknown }
if (typeof record.message === 'string' && record.message) {
return record.message
}
if (typeof record.error_code === 'string' && record.error_code) {
return record.error_code
}
}
}
return error instanceof Error ? error.message : String(error)
}
</script>
<style scoped>
@import './system-admin.css';
.reservation-catalog-admin {
align-items: stretch;
}
.catalog-intro {
display: grid;
gap: 10px;
}
.catalog-type-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.catalog-type-tabs__item {
min-height: 38px;
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: var(--th-color-slate-700);
cursor: pointer;
font-size: 13px;
font-weight: 800;
padding: 8px 12px;
}
.catalog-type-tabs__item--active {
border-color: var(--th-color-blue-600);
background: var(--th-color-blue-50);
color: var(--th-color-blue-600);
}
.catalog-create-form {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.catalog-metadata-field {
grid-column: span 2;
}
@media (max-width: 1100px) {
.catalog-create-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.catalog-create-form {
grid-template-columns: minmax(0, 1fr);
}
.catalog-metadata-field {
grid-column: auto;
}
}
</style>