实现V4目录管理后台前端
This commit is contained in:
@@ -4,15 +4,15 @@
|
||||
| --- | --- |
|
||||
| 最近更新 | 2026-07-19 |
|
||||
| 当前分支 | `feature/huangting` |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入和目录管理后台 CP1 后端 |
|
||||
| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端、前端 V4 多卡字段 lookup 第一版接入,并完成目录管理后台 CP1 后端接口;后续可做目录管理前端页面或真实 PMS / OPERA / OHIP 同步 |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入和目录管理后台 CP1 前后端 |
|
||||
| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端、前端 V4 多卡字段 lookup 第一版接入,并完成系统设置下 Account / Room Type / Rate Code 目录管理前端 CP1;后续可做测试机联调、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案 |
|
||||
|
||||
## 1. 当前 Checkpoint
|
||||
|
||||
- 名称:`M002-V4-CP13-catalog-admin-backend-cp1`
|
||||
- 状态:Done,后端已开放 Account / Room Type / Rate Code 目录管理后台 CP1 接口,支持列表查询、新增、启用 / 停用;权限使用 `RESERVATION_CATALOG_MANAGE`,强制 Bearer 登录、权限码、酒店访问权和管理审计。
|
||||
- 目标:让测试机和后续系统设置页面可以通过后端维护第一版数据库目录,停用目录不再进入普通 V4 lookup。
|
||||
- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理前端页面,不做 Market / Source 独立管理页面,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不废弃旧 V2/V3 接口。
|
||||
- 名称:`M002-V4-CP13-catalog-admin-cp1`
|
||||
- 状态:Done,后端已开放 Account / Room Type / Rate Code 目录管理后台 CP1 接口,前端已在系统设置新增 `/system/reservation-catalogs` 页面;支持列表查询、新增、启用 / 停用;权限使用 `RESERVATION_CATALOG_MANAGE`,强制 Bearer 登录、权限码、酒店访问权和管理审计。
|
||||
- 目标:让测试机和系统设置页面可以维护第一版数据库目录,停用目录不再进入普通 V4 lookup。
|
||||
- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做 Market / Source 独立管理页面,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不废弃旧 V2/V3 接口。
|
||||
|
||||
## 2. 当前优先级
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
## 5. Next Steps
|
||||
|
||||
- 后续如继续做 M002 V4,可优先进入测试机 V4 冒烟、目录管理后台前端页面、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。
|
||||
- 后续如继续做 M002 V4,可优先进入测试机 V4 目录管理与多卡 lookup 联调、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。
|
||||
- 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。
|
||||
- M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ข้อมูลบันทึกผู้ดูแล',
|
||||
|
||||
@@ -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: '管理审计数据',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
274
client/src/tests/systemReservationCatalogsView.spec.ts
Normal file
274
client/src/tests/systemReservationCatalogsView.spec.ts
Normal 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],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
808
client/src/views/system/SystemReservationCatalogsView.vue
Normal file
808
client/src/views/system/SystemReservationCatalogsView.vue
Normal 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>
|
||||
@@ -45,7 +45,7 @@
|
||||
| SuperAgent 查询上下文接口 1、2 | `docs/project/requirements/M002-ai-query-minimal-fields.md` | 阶段记录,用于理解接口 1、2 的最小字段实现;如与总契约冲突,以总契约为准。 |
|
||||
| 订单任务主流程 V3 | `docs/project/requirements/M002-order-task-workflow-v3.md` | 当前开发基线,基于 0711 P0 冻结基线和 0712 P0.1 Parent Group 修订,覆盖 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed。 |
|
||||
| 任务卡字段控件契约 V1 | `docs/project/requirements/M002-task-field-control-contract-v1.md` | 后端已返回 `fields[]` 控件元数据,规定人工复核控件复用和前后端边界;前端待接入。 |
|
||||
| 订单任务多卡模型 V4 | `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前 V4 后端主入口;后端已开放工作台统一列表、V4 订单任务列表 / 详情、卡片确认、复核解阻、S10/S99 来源通知详情和 ack,前端页面接入仍按后续 checkpoint 跟进。 |
|
||||
| 订单任务多卡模型 V4 | `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前 V4 主入口;后端已开放工作台统一列表、V4 订单任务列表 / 详情、卡片确认、复核解阻、S10/S99 来源通知详情和 ack;前端已完成 V4 页面第一版、目录 lookup 接入、订单详情 V4 时间线消费和系统设置目录管理 CP1。 |
|
||||
| Manual Invoice 手工开票生成 | `docs/project/requirements/M009-manual-invoice-generation-v1.md` | 当前有效;后端 CP2 已支持无订单 / 无任务手工填写字段、填 Excel 模板、转 PDF、OSS 输出和生成记录。 |
|
||||
| Rooming List Excel 生成 | `docs/project/requirements/M010-rooming-list-excel-generation-v1.md` | 当前有效;后端 CP1 已支持前端上传来源名单并填写目标字段,同步生成 `.xlsx` 直接下载;前端 V1 已新增 `/reservation/rooming-lists/new`,按 Blob 下载处理,不落库、不上传 OSS。 |
|
||||
| 订单任务主流程 V2 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 已实现阶段记录,保留用于理解当前代码中的 S000/S999、订单任务流转和 OPERA 模拟骨架。 |
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
| `GET /api/admin/reservation/catalogs/room-types` / `GET /api/admin/reservation/catalogs/rate-codes` | 管理后台 Room Type / Rate Code 目录列表 | 必须带 `RESERVATION_CATALOG_MANAGE`;支持 `hotel_id`、`keyword`、`status`、`page_num`、`page_size`;`keyword` 搜索 code 时大小写不敏感,显示名仍按数据库比较规则;返回 `items[] + page`,包含 `id`、`catalog_type`、`code`、`display_name`、`status`、`catalog_source`、`external_id`、`sort_order`、`catalog_version`、`metadata_json`、`version`、`created_at`、`updated_at`。 |
|
||||
| `POST /api/admin/reservation/catalogs/room-types` / `POST /api/admin/reservation/catalogs/rate-codes` | 管理后台新增 Room Type / Rate Code | 必须带 `RESERVATION_CATALOG_MANAGE`;请求 `hotel_id`、`code`、`display_name`,可选 `external_id`、`sort_order`、`catalog_version`、`metadata_json`;新增后默认 `ACTIVE`、`catalog_source=SYSTEM_MANAGED`,写管理审计。 |
|
||||
| `PUT /api/admin/reservation/catalogs/room-types/{catalogId}/status` / `PUT /api/admin/reservation/catalogs/rate-codes/{catalogId}/status` | 管理后台启用 / 停用 Room Type / Rate Code | 必须带 `RESERVATION_CATALOG_MANAGE`;请求 `{"status":"ACTIVE"}` 或 `{"status":"DISABLED"}`;按记录所属酒店校验;停用后对应普通 lookup 不再返回;如果提交的状态和当前状态一致,后端幂等返回当前记录,不新增管理审计。 |
|
||||
|
||||
前端已在系统设置下新增 `/system/reservation-catalogs` 消费上述目录管理接口。页面入口要求 `RESERVATION_CATALOG_MANAGE`,列表过滤直接传 `hotel_id`、`keyword`、`status`、`page_num`、`page_size`;Account 新增第一版固定提交 `market_code=LEISURE`、`source_code=TRAVEL_AGENT`;状态重复提交按成功提示处理,不额外弹失败。
|
||||
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code,嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 |
|
||||
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 |
|
||||
|
||||
@@ -499,7 +499,7 @@ SuperAgent 当前不调用本 lookup API。SuperAgent 目录供给后续有两
|
||||
| --- | --- | --- |
|
||||
| M002-V4-CP11 | DB 管理目录与 Lookup API V1 | 新增 Account / Code 目录表、Repository、DirectoryService DB 实现、Account / Room Type / Rate Code lookup 查询接口、权限和测试 |
|
||||
| M002-V4-CP12 | 前端 Lookup 接入 | V4 卡片字段渲染按 `options_source` 调用 lookup,替换固定种子硬编码选项,处理 stale / warning / 空目录 |
|
||||
| M002-V4-CP13 | 目录管理后台 V1 | CP1 已完成 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口、`RESERVATION_CATALOG_MANAGE` 权限和管理审计;前端页面、Market / Source 独立管理后置 |
|
||||
| M002-V4-CP13 | 目录管理后台 V1 | CP1 已完成 Account / Room Type / Rate Code 前后端列表、新增、启用 / 停用闭环、`RESERVATION_CATALOG_MANAGE` 权限和管理审计;Market / Source 独立管理后置 |
|
||||
| M002-V4-CP14 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run 表、失败重试、最后成功快照、同步状态管理入口 |
|
||||
| M002-V4-CP15 | SuperAgent 目录供给 | 明确目录版本如何给 SuperAgent,必要时新增机器目录接口或导出包 |
|
||||
|
||||
@@ -507,7 +507,7 @@ CP11 已作为后端第一步落地,因为它不依赖真实 PMS,也能让
|
||||
|
||||
## 15. 仍需确认的问题
|
||||
|
||||
1. Account 的第一版真实维护入口后端已放在系统管理后台接口;前端菜单和页面布局仍需后续确认。
|
||||
1. Account 的第一版真实维护入口已放在系统设置 `/system/reservation-catalogs`;前端第一版固定使用 `market_code=LEISURE`、`source_code=TRAVEL_AGENT`,Market / Source 独立管理仍需后续确认。
|
||||
2. Account code 是否继续使用本系统定义的稳定 code,例如 `QBD_TRAVEL`,还是必须对齐 PMS profile code。
|
||||
3. Market / Source 是否只允许随 Account 派生,还是未来允许用户在 Basic Information 中单独改选。
|
||||
4. Room Type 第一版后端已支持系统管理维护;后续是否仍要接 PMS / OHIP 同步替换为主来源待确认。
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
| `/api/admin/permissions` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ROLE_MANAGE` | 保持只读;前端不能自造权限码 | 不需要写审计 |
|
||||
| `/api/admin/menus/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_MENU_MANAGE`;`GET /tree`、`PUT /tree-order` 已沿用该权限 | 保持;菜单可见性不替代后端权限;批量树排序只允许修改 `parent_id` 和 `sort_order`,`sort_order` 为空时按请求顺序生成稳定排序 | 写操作必须记录管理审计,树排序审计记录调整前后的父级和排序;树查询不写审计 |
|
||||
| `/api/admin/hotels/**` | `FRONTEND_ADMIN` | 已强制登录和 `HOTEL_MANAGE` | 保持;单酒店阶段只能一家 `ACTIVE` | 写操作必须记录管理审计 |
|
||||
| `/api/admin/reservation/catalogs/**` | `FRONTEND_ADMIN` | 已实现目录管理后台 CP1;强制登录、`RESERVATION_CATALOG_MANAGE` 和目标酒店访问权 | 保持;第一版只开放 Account、Room Type、Rate Code 列表、新增、启用 / 停用;Market / Source 独立管理和真实 PMS 同步后置;停用目录不再进入普通 lookup | 新增和状态实际变化必须记录管理审计;重复提交相同状态按幂等返回,不新增审计;列表查询不写审计;不得返回 PMS 原始响应、Secret 或外部同步错误详情 |
|
||||
| `/api/admin/reservation/catalogs/**` | `FRONTEND_ADMIN` | 已实现目录管理后台 CP1;前端入口为 `/system/reservation-catalogs`;强制登录、`RESERVATION_CATALOG_MANAGE` 和目标酒店访问权 | 保持;第一版只开放 Account、Room Type、Rate Code 列表、新增、启用 / 停用;Market / Source 独立管理和真实 PMS 同步后置;停用目录不再进入普通 lookup | 新增和状态实际变化必须记录管理审计;重复提交相同状态按幂等返回,不新增审计;列表查询不写审计;不得返回 PMS 原始响应、Secret 或外部同步错误详情 |
|
||||
| `GET /api/admin/audits` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ADMIN_CONSOLE_ACCESS` | 保持;不返回 Secret、密码或 token | 查询审计不再写审计 |
|
||||
|
||||
### 3.5 调试和系统接口
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# M002 V4 Catalog Admin Frontend CP1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement the Reservation V4 catalog management frontend page under System Settings for Account, Room Type, and Rate Code catalogs.
|
||||
|
||||
**Architecture:** Extend the existing system admin module with typed admin catalog service functions, a guarded `/system/reservation-catalogs` route, and a single focused Vue page that switches between the three catalog types. The page follows existing system admin list/form/pagination patterns and never bypasses backend permission, hotel, or catalog validation.
|
||||
|
||||
**Tech Stack:** Vue 3.5, TypeScript, Vue Router, Pinia auth store, Vue I18n, Vitest, existing `httpClient` and system admin CSS.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- API calls must stay in `client/src/services/systemAdminService.ts`.
|
||||
- Types must stay in `client/src/types/systemAdmin.ts`.
|
||||
- Route and tab entry require `RESERVATION_CATALOG_MANAGE`.
|
||||
- Account create uses default `market_code=LEISURE` and `source_code=TRAVEL_AGENT`.
|
||||
- List filters are `hotel_id`, `keyword`, `status`, `page_num`, `page_size`.
|
||||
- No backend changes, no PMS / OPERA / OHIP integration, no V4 task card behavior changes.
|
||||
- Do not submit secrets, demo keys, build artifacts, IDE files, or unrelated working tree changes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Service And Route Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/types/auth.ts`
|
||||
- Modify: `client/src/types/systemAdmin.ts`
|
||||
- Modify: `client/src/services/systemAdminService.ts`
|
||||
- Modify: `client/src/router/index.ts`
|
||||
- Test: `client/src/tests/systemAdminService.spec.ts`
|
||||
- Test: `client/src/tests/reservationRouter.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `fetchAdminReservationCatalogAccounts`, `createAdminReservationCatalogAccount`, `updateAdminReservationCatalogAccountStatus`.
|
||||
- Produces: `fetchAdminReservationCatalogRoomTypes`, `createAdminReservationCatalogRoomType`, `updateAdminReservationCatalogRoomTypeStatus`.
|
||||
- Produces: `fetchAdminReservationCatalogRateCodes`, `createAdminReservationCatalogRateCode`, `updateAdminReservationCatalogRateCodeStatus`.
|
||||
|
||||
- [x] **Step 1: Write failing service tests**
|
||||
|
||||
```ts
|
||||
await fetchAdminReservationCatalogAccounts({ hotel_id: 'HOTEL-TEST', keyword: 'qbd', status: 'ACTIVE', page_num: 2, page_size: 10 })
|
||||
await createAdminReservationCatalogAccount({ hotel_id: 'HOTEL-TEST', account_code: 'ACME', account_name: 'Acme', market_code: 'LEISURE', source_code: 'TRAVEL_AGENT' })
|
||||
await updateAdminReservationCatalogAccountStatus('10001', { status: 'DISABLED' })
|
||||
```
|
||||
|
||||
- [x] **Step 2: Write failing router tests**
|
||||
|
||||
```ts
|
||||
expect(router.resolve('/system/reservation-catalogs').name).toBe('system-reservation-catalogs')
|
||||
expect(router.resolve('/system/reservation-catalogs').meta.permission).toBe('RESERVATION_CATALOG_MANAGE')
|
||||
```
|
||||
|
||||
- [x] **Step 3: Implement types, services, and route**
|
||||
|
||||
Add the catalog request/response interfaces and route metadata, using only backend paths documented in `M002-v4-real-catalog-lookup-api-design.md`.
|
||||
|
||||
- [x] **Step 4: Verify task tests**
|
||||
|
||||
Run: `CI=true pnpm --dir client test -- systemAdminService.spec.ts reservationRouter.spec.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Catalog Management Page
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/views/system/SystemReservationCatalogsView.vue`
|
||||
- Modify: `client/src/views/system/SystemAdminLayoutView.vue`
|
||||
- Modify: `client/src/i18n/locales/zh-CN.ts`
|
||||
- Modify: `client/src/i18n/locales/en-US.ts`
|
||||
- Modify: `client/src/i18n/locales/th-TH.ts`
|
||||
- Test: `client/src/tests/systemReservationCatalogsView.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes service functions from Task 1.
|
||||
- Produces a page that lists, creates, enables, and disables Account, Room Type, and Rate Code records.
|
||||
|
||||
- [x] **Step 1: Write failing page tests**
|
||||
|
||||
```ts
|
||||
expect(wrapper.text()).toContain('Reservation V4 目录管理')
|
||||
expect(fetchAdminReservationCatalogAccounts).toHaveBeenCalledWith(expect.objectContaining({ hotel_id: 'HOTEL-TEST' }))
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
expect(createAdminReservationCatalogAccount).toHaveBeenCalledWith(expect.objectContaining({ market_code: 'LEISURE', source_code: 'TRAVEL_AGENT' }))
|
||||
```
|
||||
|
||||
- [x] **Step 2: Implement page UI**
|
||||
|
||||
Use existing system admin sections: filters, create form, catalog table, pagination, and row status action buttons.
|
||||
|
||||
- [x] **Step 3: Add i18n keys**
|
||||
|
||||
Add `nav.systemReservationCatalogs` and `systemAdmin.catalogs.*` to zh-CN, en-US, and th-TH.
|
||||
|
||||
- [x] **Step 4: Verify page tests**
|
||||
|
||||
Run: `CI=true pnpm --dir client test -- systemReservationCatalogsView.spec.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verification, Review, And Commit
|
||||
|
||||
**Files:**
|
||||
- Review all changed frontend files.
|
||||
- Commit only files related to this frontend CP1.
|
||||
|
||||
- [ ] **Step 1: Run checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
CI=true pnpm --dir client lint
|
||||
CI=true pnpm --dir client typecheck
|
||||
CI=true pnpm --dir client test
|
||||
CI=true pnpm --dir client build
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Code review**
|
||||
|
||||
Review for endpoint paths, permission gates, hotel filter behavior, idempotent status actions, stale/disabled catalog explanation, and accidental staging of unrelated files.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add <only M002 V4 catalog admin frontend files>
|
||||
git commit -m "实现V4目录管理后台前端"
|
||||
```
|
||||
Reference in New Issue
Block a user