新增房表生成前端页面

This commit is contained in:
andy
2026-07-18 12:58:01 +07:00
parent d3a52e7605
commit 0715e8334f
16 changed files with 1694 additions and 5 deletions

View File

@@ -27,6 +27,7 @@ export default {
orders: 'Order list',
taskQueue: 'Task queue',
manualInvoice: 'Manual invoice',
roomingList: 'Rooming list',
sourceMessages: 'Source messages',
systemStatus: 'System status',
orderDetail: 'Order detail',
@@ -347,6 +348,60 @@ export default {
chargePositive: 'Quantity, rate, and nights must be greater than 0.',
},
},
roomingList: {
eyebrow: 'Reservation tool',
title: 'Generate Rooming List Excel',
subtitle: 'Upload the travel group list, enter rooming defaults, and download the generated hotel import workbook.',
currentHotel: 'Current hotel',
sourceSection: 'Source list and required fields',
targetSection: 'Target Excel defaults',
sourceFile: 'Source Excel file',
sourceFileHint: 'Supports .xls / .xlsx. The frontend does not read or log guest list content.',
noFile: 'Choose a .xls / .xlsx file',
peoplePerRoom: 'People per room',
arrival: 'Arrival',
departure: 'Departure',
roomType: 'Room type',
roomTypePlaceholder: 'e.g. DLX / TWN',
titleColumn: 'Title',
rateCode: 'Rate Code',
adults: 'Adults',
children: 'Children',
paymentType: 'Payment Type',
vip: 'VIP',
nationality: 'Nationality',
email: 'Email',
idType: 'ID Type',
idNumber: 'ID Number',
generate: 'Generate and download Excel',
generating: 'Generating',
validationTitle: 'Please fix the form',
resultSuccess: 'Generation result',
resultDownloaded: 'Excel has been generated and the browser download was triggered.',
downloadedFile: 'Downloaded file',
fieldErrors: {
fileRequired: 'Choose a source Excel file.',
fileType: 'Only .xls / .xlsx files are supported.',
positiveInteger: 'People per room must be greater than 0.',
nonNegativeInteger: 'Must be 0 or a positive integer.',
arrivalRequired: 'Enter arrival date.',
departureRequired: 'Enter departure date.',
dateFormat: 'Date format must be yyyy-MM-dd.',
departureAfterArrival: 'Departure must be later than arrival.',
roomTypeRequired: 'Enter room type.',
invalidEmail: 'Invalid email.',
},
errors: {
FRONTEND_VALIDATION: 'Form validation failed. Check required fields.',
ROOMING_LIST_VALIDATION_FAILED: 'Rooming List field validation failed. Please check the form.',
ROOMING_LIST_SOURCE_FILE_INVALID: 'The source list is invalid. Check the Excel headers and file format.',
FRONTEND_PERMISSION_DENIED: 'Your account does not have Rooming List generation permission.',
HOTEL_ACCESS_DENIED: 'Your account cannot access the selected hotel.',
AUTH_TOKEN_REQUIRED: 'Please sign in before generating a Rooming List.',
AUTH_SESSION_INVALID: 'Your session has expired. Please sign in again.',
UNKNOWN: 'Rooming List generation failed. Please retry later.',
},
},
order: {
summary: 'Order summary',
taskQueue: 'Order task queue',

View File

@@ -27,6 +27,7 @@ export default {
orders: 'รายการออเดอร์',
taskQueue: 'คิวงาน',
manualInvoice: 'ออกใบแจ้งหนี้เอง',
roomingList: 'สร้าง Rooming List',
sourceMessages: 'แหล่งที่มาข้อความ',
systemStatus: 'สถานะระบบ',
orderDetail: 'รายละเอียดออเดอร์',
@@ -347,6 +348,60 @@ export default {
chargePositive: 'Quantity, Rate และ Nights ต้องมากกว่า 0',
},
},
roomingList: {
eyebrow: 'เครื่องมือ Reservation',
title: 'สร้าง Rooming List Excel',
subtitle: 'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้องและค่าเริ่มต้น แล้วดาวน์โหลดไฟล์ Excel ที่สร้างแล้ว',
currentHotel: 'โรงแรมปัจจุบัน',
sourceSection: 'ไฟล์รายชื่อและข้อมูลจำเป็น',
targetSection: 'ค่าเริ่มต้นของ Excel ปลายทาง',
sourceFile: 'ไฟล์ Excel ต้นทาง',
sourceFileHint: 'รองรับ .xls / .xlsx และหน้าเว็บจะไม่อ่านหรือบันทึกข้อมูลรายชื่อแขก',
noFile: 'เลือกไฟล์ .xls / .xlsx',
peoplePerRoom: 'จำนวนคนต่อห้อง',
arrival: 'Arrival',
departure: 'Departure',
roomType: 'Room Type',
roomTypePlaceholder: 'เช่น DLX / TWN',
titleColumn: 'Title',
rateCode: 'Rate Code',
adults: 'Adults',
children: 'Children',
paymentType: 'Payment Type',
vip: 'VIP',
nationality: 'Nationality',
email: 'Email',
idType: 'ID Type',
idNumber: 'ID Number',
generate: 'สร้างและดาวน์โหลด Excel',
generating: 'กำลังสร้าง',
validationTitle: 'กรุณาแก้ไขแบบฟอร์ม',
resultSuccess: 'ผลการสร้าง',
resultDownloaded: 'สร้าง Excel แล้ว และเริ่มดาวน์โหลดในเบราว์เซอร์แล้ว',
downloadedFile: 'ไฟล์ที่ดาวน์โหลด',
fieldErrors: {
fileRequired: 'กรุณาเลือกไฟล์ Excel ต้นทาง',
fileType: 'รองรับเฉพาะไฟล์ .xls / .xlsx',
positiveInteger: 'จำนวนคนต่อห้องต้องมากกว่า 0',
nonNegativeInteger: 'ต้องเป็น 0 หรือจำนวนเต็มบวก',
arrivalRequired: 'กรุณากรอก Arrival',
departureRequired: 'กรุณากรอก Departure',
dateFormat: 'รูปแบบวันที่ต้องเป็น yyyy-MM-dd',
departureAfterArrival: 'Departure ต้องอยู่หลัง Arrival',
roomTypeRequired: 'กรุณากรอก Room Type',
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
},
errors: {
FRONTEND_VALIDATION: 'ตรวจสอบแบบฟอร์มไม่ผ่าน กรุณาตรวจฟิลด์ที่จำเป็น',
ROOMING_LIST_VALIDATION_FAILED: 'ตรวจสอบข้อมูล Rooming List ไม่ผ่าน กรุณาตรวจแบบฟอร์ม',
ROOMING_LIST_SOURCE_FILE_INVALID: 'ไฟล์รายชื่อต้นทางไม่ถูกต้อง กรุณาตรวจหัวตารางและรูปแบบไฟล์',
FRONTEND_PERMISSION_DENIED: 'บัญชีนี้ไม่มีสิทธิ์สร้าง Rooming List',
HOTEL_ACCESS_DENIED: 'บัญชีนี้ไม่มีสิทธิ์เข้าถึงโรงแรมที่เลือก',
AUTH_TOKEN_REQUIRED: 'กรุณาเข้าสู่ระบบก่อนสร้าง Rooming List',
AUTH_SESSION_INVALID: 'เซสชันหมดอายุ กรุณาเข้าสู่ระบบใหม่',
UNKNOWN: 'สร้าง Rooming List ไม่สำเร็จ กรุณาลองใหม่ภายหลัง',
},
},
order: {
summary: 'สรุปออเดอร์',
taskQueue: 'คิวงานของออเดอร์นี้',

View File

@@ -27,6 +27,7 @@ export default {
orders: '订单列表',
taskQueue: '任务队列',
manualInvoice: '手工开票',
roomingList: '房表生成',
sourceMessages: '消息来源',
systemStatus: '系统状态',
orderDetail: '订单详情',
@@ -347,6 +348,60 @@ export default {
chargePositive: '费用明细的 Quantity、Rate、Nights 必须大于 0。',
},
},
roomingList: {
eyebrow: '预订工具',
title: '生成 Rooming List Excel',
subtitle: '上传旅行团名单 Excel填写每房人数和目标列默认值生成酒店可导入的 Rooming List。',
currentHotel: '当前酒店',
sourceSection: '来源名单与必填信息',
targetSection: '目标 Excel 默认值',
sourceFile: '来源 Excel 文件',
sourceFileHint: '支持 .xls / .xlsx前端不会读取或记录名单内容。',
noFile: '选择 .xls / .xlsx 文件',
peoplePerRoom: '每间房人数',
arrival: '入住日期',
departure: '离店日期',
roomType: '房型',
roomTypePlaceholder: '例如 DLX / TWN',
titleColumn: '称谓Title',
rateCode: '价格代码Rate Code',
adults: '成人数Adults',
children: '儿童数Children',
paymentType: '付款方式Payment Type',
vip: 'VIP',
nationality: '国籍Nationality',
email: '邮箱Email',
idType: '证件类型ID Type',
idNumber: '证件号码ID Number',
generate: '生成并下载 Excel',
generating: '生成中',
validationTitle: '请先修正表单',
resultSuccess: '生成结果',
resultDownloaded: 'Excel 已生成,并已触发浏览器下载。',
downloadedFile: '下载文件',
fieldErrors: {
fileRequired: '请选择来源 Excel 文件。',
fileType: '仅支持 .xls / .xlsx 文件。',
positiveInteger: '每间房人数必须大于 0。',
nonNegativeInteger: '必须是 0 或正整数。',
arrivalRequired: '请填写入住日期。',
departureRequired: '请填写离店日期。',
dateFormat: '日期格式必须为 yyyy-MM-dd。',
departureAfterArrival: '离店日期必须晚于入住日期。',
roomTypeRequired: '请填写房型。',
invalidEmail: 'Email 格式不正确。',
},
errors: {
FRONTEND_VALIDATION: '表单校验未通过,请检查必填字段。',
ROOMING_LIST_VALIDATION_FAILED: 'Rooming List 字段校验失败,请检查表单内容。',
ROOMING_LIST_SOURCE_FILE_INVALID: '来源名单文件不合法,请检查 Excel 表头和文件格式。',
FRONTEND_PERMISSION_DENIED: '当前账号没有生成 Rooming List 的权限。',
HOTEL_ACCESS_DENIED: '当前账号不能访问所选酒店。',
AUTH_TOKEN_REQUIRED: '请先登录后再生成 Rooming List。',
AUTH_SESSION_INVALID: '登录已失效,请重新登录后再生成。',
UNKNOWN: 'Rooming List 生成失败,请稍后重试。',
},
},
order: {
summary: '订单摘要',
taskQueue: '同订单任务队列',

View File

@@ -175,6 +175,8 @@ const menuLabelKeys: Record<string, string> = {
RESERVATION_TASKS: 'nav.taskQueue',
RESERVATION_MANUAL_INVOICE: 'nav.manualInvoice',
RESERVATION_INVOICES: 'nav.manualInvoice',
RESERVATION_ROOMING_LIST: 'nav.roomingList',
RESERVATION_ROOMING_LISTS: 'nav.roomingList',
DEBUG_EML_SUPERAGENT: 'nav.debugEml',
SYSTEM_SETTINGS: 'nav.systemSettings',
}

View File

@@ -71,6 +71,15 @@ export const router = createRouter({
permission: 'RESERVATION_INVOICE_GENERATE',
},
},
{
path: '/reservation/rooming-lists/new',
name: 'reservation-rooming-list',
component: () => import('@/views/reservation/ReservationRoomingListGenerationView.vue'),
meta: {
titleKey: 'nav.roomingList',
permission: 'RESERVATION_ROOMING_LIST_GENERATE',
},
},
{
path: '/reservation/orders/:orderId',
name: 'reservation-order-detail',

View File

@@ -0,0 +1,165 @@
import { getStoredAccessToken } from '@/services/authSession'
import { isAuthSessionInvalidPayload, notifyUnauthorized } from '@/services/httpClient'
import type {
RoomingListErrorResponse,
RoomingListGenerationDownload,
RoomingListGenerationErrorCode,
RoomingListGenerationInput,
} from '@/types/roomingList'
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
const roomingListGenerationEndpoint = '/api/reservation/rooming-lists/generations'
const roomingListExcelContentType =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const defaultRoomingListFileName = 'rooming-list.xlsx'
export class RoomingListGenerationError extends Error {
readonly status: number
readonly errorCode: RoomingListGenerationErrorCode
readonly details: string[]
constructor(
message: string,
status: number,
errorCode: RoomingListGenerationErrorCode,
details: string[],
) {
super(message)
this.name = 'RoomingListGenerationError'
this.status = status
this.errorCode = errorCode
this.details = details
}
}
export async function generateReservationRoomingListExcel(
input: RoomingListGenerationInput,
): Promise<RoomingListGenerationDownload> {
const response = await fetch(`${apiBaseUrl}${roomingListGenerationEndpoint}`, {
method: 'POST',
headers: buildRoomingListHeaders(),
body: buildRoomingListForm(input),
})
if (!response.ok) {
const payload = await readErrorPayload(response)
if (isAuthSessionInvalidPayload(payload) || response.status === 401) {
notifyUnauthorized()
}
throw normalizeGenerationError(response.status, payload)
}
const blob = await response.blob()
return {
blob,
fileName: fileNameFromContentDisposition(response.headers.get('content-disposition')),
}
}
function buildRoomingListHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: roomingListExcelContentType,
}
const token = getStoredAccessToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}
function buildRoomingListForm(input: RoomingListGenerationInput): FormData {
const form = new FormData()
form.append('file', input.file)
appendOptionalText(form, 'hotel_id', input.hotelId)
form.append('people_per_room', String(input.peoplePerRoom))
form.append('arrival', input.arrival)
form.append('departure', input.departure)
form.append('room_type', input.roomType)
appendOptionalText(form, 'title', input.title)
appendOptionalText(form, 'rate_code', input.rateCode)
appendOptionalNumber(form, 'adults', input.adults)
appendOptionalNumber(form, 'children', input.children)
appendOptionalText(form, 'payment_type', input.paymentType)
appendOptionalText(form, 'vip', input.vip)
appendOptionalText(form, 'nationality', input.nationality)
appendOptionalText(form, 'email', input.email)
appendOptionalText(form, 'id_type', input.idType)
appendOptionalText(form, 'id_number', input.idNumber)
return form
}
function appendOptionalText(form: FormData, key: string, value: string | null | undefined): void {
const normalized = value?.trim()
if (normalized) {
form.append(key, normalized)
}
}
function appendOptionalNumber(form: FormData, key: string, value: number | null | undefined): void {
if (typeof value === 'number' && Number.isFinite(value)) {
form.append(key, String(value))
}
}
async function readErrorPayload(response: Response): Promise<unknown> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return {
message: await response.text(),
}
}
return response.json()
}
function normalizeGenerationError(status: number, payload: unknown): RoomingListGenerationError {
const errorPayload = isRoomingListErrorResponse(payload) ? payload : {}
return new RoomingListGenerationError(
errorPayload.message || `Request failed with status ${status}`,
status,
errorPayload.error_code || 'UNKNOWN',
normalizeDetails(errorPayload.details),
)
}
function isRoomingListErrorResponse(payload: unknown): payload is RoomingListErrorResponse {
return typeof payload === 'object' && payload !== null && !Array.isArray(payload)
}
function normalizeDetails(details: unknown): string[] {
if (!Array.isArray(details)) {
return []
}
return details.filter((item): item is string => typeof item === 'string')
}
export function fileNameFromContentDisposition(contentDisposition: string | null): string {
if (!contentDisposition) {
return defaultRoomingListFileName
}
const encodedFileName = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(contentDisposition)?.[1]
if (encodedFileName) {
return safeFileName(decodeFileName(encodedFileName))
}
const quotedFileName = /filename="([^"]+)"/i.exec(contentDisposition)?.[1]
if (quotedFileName) {
return safeFileName(quotedFileName)
}
const plainFileName = /filename=([^;]+)/i.exec(contentDisposition)?.[1]
if (plainFileName) {
return safeFileName(plainFileName)
}
return defaultRoomingListFileName
}
function decodeFileName(value: string): string {
try {
return decodeURIComponent(value.trim().replace(/^"|"$/g, ''))
} catch {
return value.trim().replace(/^"|"$/g, '')
}
}
function safeFileName(value: string): string {
const normalized = value.trim().replace(/[/\\]/g, '_')
return normalized || defaultRoomingListFileName
}

View File

@@ -91,6 +91,18 @@ function createManualInvoiceMenu(): AuthMenuResult {
}
}
function createRoomingListMenu(): AuthMenuResult {
return {
menu_code: 'RESERVATION_ROOMING_LIST',
menu_name: 'Rooming List',
route_path: '/reservation/rooming-lists/new',
component_key: 'ReservationRoomingList',
icon_key: 'pi pi-file-excel',
permission_code: 'RESERVATION_ROOMING_LIST_GENERATE',
sort_order: 26,
}
}
function createSystemMenu(): AuthMenuResult {
return {
menu_code: 'SYSTEM_SETTINGS',
@@ -137,6 +149,11 @@ async function mountShell(menus = [createReservationOrdersMenu(), createReservat
component: { template: '<div />' },
meta: { titleKey: 'nav.manualInvoice' },
},
{
path: '/reservation/rooming-lists/new',
component: { template: '<div />' },
meta: { titleKey: 'nav.roomingList' },
},
{
path: '/system',
component: { template: '<div />' },
@@ -199,6 +216,13 @@ describe('ReservationAppShell', () => {
expect(wrapper.text()).toContain('手工开票')
})
it('shows Rooming List only when backend menus include it', async () => {
const wrapper = await mountShell([createReservationOrdersMenu(), createRoomingListMenu()])
expect(wrapper.find('a[href="/reservation/rooming-lists/new"]').exists()).toBe(true)
expect(wrapper.text()).toContain('房表生成')
})
it('shows System Settings only when backend menus include it', async () => {
const wrapper = await mountShell([createReservationOrdersMenu(), createSystemMenu()])

View File

@@ -43,6 +43,7 @@ describe('reservation router', () => {
expect(router.resolve('/reservation/orders').name).toBe('reservation-order-list')
expect(router.resolve('/reservation/tasks').name).toBe('reservation-task-list')
expect(router.resolve('/reservation/invoices/new').name).toBe('reservation-manual-invoice')
expect(router.resolve('/reservation/rooming-lists/new').name).toBe('reservation-rooming-list')
expect(router.resolve('/reservation/orders/20001').name).toBe('reservation-order-detail')
expect(router.resolve('/reservation/tasks/10001').name).toBe('reservation-task-detail')
expect(router.resolve('/reservation/source-messages/30001/conversation').name).toBe(
@@ -69,6 +70,9 @@ describe('reservation router', () => {
expect(router.resolve('/reservation/tasks').meta.permission).toBe('RESERVATION_TASK_READ')
expect(router.resolve('/reservation/tasks/10001').meta.permission).toBe('RESERVATION_TASK_READ')
expect(router.resolve('/reservation/invoices/new').meta.permission).toBe('RESERVATION_INVOICE_GENERATE')
expect(router.resolve('/reservation/rooming-lists/new').meta.permission).toBe(
'RESERVATION_ROOMING_LIST_GENERATE',
)
expect(router.resolve('/reservation/source-messages/30001/conversation').meta.permission).toBe(
'SOURCE_MESSAGE_ORIGINAL_READ',
)

View File

@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearStoredAccessToken, setStoredAccessToken } from '@/services/authSession'
import {
generateReservationRoomingListExcel,
RoomingListGenerationError,
} from '@/services/roomingListService'
import { setUnauthorizedHandler } from '@/services/httpClient'
import type { RoomingListGenerationInput } from '@/types/roomingList'
function createInput(): RoomingListGenerationInput {
return {
file: new File(['passport names'], 'source.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
hotelId: 'HOTEL-TEST',
peoplePerRoom: 2,
arrival: '2026-07-20',
departure: '2026-07-22',
roomType: 'DLX',
title: 'MR',
rateCode: 'BAR',
adults: 2,
children: 0,
paymentType: 'BT',
vip: 'VIP1',
nationality: 'TH',
email: 'guest@example.test',
idType: 'Passport',
idNumber: 'A1234567',
}
}
function createExcelResponse(headers: Record<string, string> = {}): Response {
return new Response(new Blob(['xlsx-binary']), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
...headers,
},
})
}
function createJsonResponse(payload: unknown, status: number): Response {
return new Response(JSON.stringify(payload), {
status,
headers: {
'Content-Type': 'application/json',
},
})
}
describe('roomingListService', () => {
beforeEach(() => {
vi.restoreAllMocks()
clearStoredAccessToken()
setUnauthorizedHandler(null)
})
it('uploads source Excel as FormData and returns the generated workbook blob', async () => {
const input = createInput()
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
createExcelResponse({
'Content-Disposition': 'attachment; filename="rooming-list-HOTEL-TEST-20260718.xlsx"',
}),
)
setStoredAccessToken('session-token')
const result = await generateReservationRoomingListExcel(input)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('/api/reservation/rooming-lists/generations')
expect(init).toMatchObject({
method: 'POST',
headers: {
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
Authorization: 'Bearer session-token',
},
})
expect(init?.headers).not.toHaveProperty('Content-Type')
expect(init?.body).toBeInstanceOf(FormData)
const formData = init?.body as FormData
expect(formData.get('file')).toBe(input.file)
expect(formData.get('hotel_id')).toBe('HOTEL-TEST')
expect(formData.get('people_per_room')).toBe('2')
expect(formData.get('arrival')).toBe('2026-07-20')
expect(formData.get('departure')).toBe('2026-07-22')
expect(formData.get('room_type')).toBe('DLX')
expect(formData.get('title')).toBe('MR')
expect(formData.get('rate_code')).toBe('BAR')
expect(formData.get('adults')).toBe('2')
expect(formData.get('children')).toBe('0')
expect(formData.get('payment_type')).toBe('BT')
expect(formData.get('vip')).toBe('VIP1')
expect(formData.get('nationality')).toBe('TH')
expect(formData.get('email')).toBe('guest@example.test')
expect(formData.get('id_type')).toBe('Passport')
expect(formData.get('id_number')).toBe('A1234567')
expect(result.fileName).toBe('rooming-list-HOTEL-TEST-20260718.xlsx')
expect(result.blob.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
})
it('uses the default file name when Content-Disposition does not provide one', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(createExcelResponse())
const result = await generateReservationRoomingListExcel({
...createInput(),
hotelId: undefined,
title: '',
rateCode: '',
adults: null,
children: null,
paymentType: '',
vip: '',
nationality: '',
email: '',
idType: '',
idNumber: '',
})
expect(result.fileName).toBe('rooming-list.xlsx')
})
it('throws a typed error with backend details for validation failures', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
createJsonResponse(
{
error_code: 'ROOMING_LIST_VALIDATION_FAILED',
message: 'Rooming List 字段校验失败。',
details: ['people_per_room: 必填字段缺失。', 'departure: 必须晚于 arrival。'],
},
400,
),
)
await expect(generateReservationRoomingListExcel(createInput())).rejects.toMatchObject({
status: 400,
errorCode: 'ROOMING_LIST_VALIDATION_FAILED',
message: 'Rooming List 字段校验失败。',
details: ['people_per_room: 必填字段缺失。', 'departure: 必须晚于 arrival。'],
} satisfies Partial<RoomingListGenerationError>)
})
it('runs the shared unauthorized handler for auth session failures', async () => {
const unauthorizedHandler = vi.fn()
setUnauthorizedHandler(unauthorizedHandler)
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
createJsonResponse(
{
error_code: 'AUTH_SESSION_INVALID',
message: '登录已失效,请重新登录。',
details: [],
},
401,
),
)
await expect(generateReservationRoomingListExcel(createInput())).rejects.toMatchObject({
status: 401,
errorCode: 'AUTH_SESSION_INVALID',
} satisfies Partial<RoomingListGenerationError>)
expect(unauthorizedHandler).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,252 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import enUS from '@/i18n/locales/en-US'
import thTH from '@/i18n/locales/th-TH'
import zhCN from '@/i18n/locales/zh-CN'
import {
generateReservationRoomingListExcel,
RoomingListGenerationError,
} from '@/services/roomingListService'
import { useAuthStore } from '@/stores/authStore'
import ReservationRoomingListGenerationView from '@/views/reservation/ReservationRoomingListGenerationView.vue'
vi.mock('@/services/roomingListService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/roomingListService')>()
return {
...actual,
generateReservationRoomingListExcel: vi.fn(),
}
})
const nativeCreateObjectURL = URL.createObjectURL
const nativeRevokeObjectURL = URL.revokeObjectURL
function mountView(options: { permissions?: string[] } = {}) {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS,
'th-TH': thTH,
},
})
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().applyLoginResult({
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-18T00:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
],
permissions: options.permissions ?? ['RESERVATION_ROOMING_LIST_GENERATE'],
menus: [],
})
return mount(ReservationRoomingListGenerationView, {
global: {
plugins: [i18n, pinia],
},
})
}
async function chooseFile(wrapper: ReturnType<typeof mountView>, file: File) {
const input = wrapper.find('[data-testid="rooming-list-file"]')
Object.defineProperty(input.element, 'files', {
configurable: true,
value: [file],
})
await input.trigger('change')
}
async function fillRequiredFields(wrapper: ReturnType<typeof mountView>) {
await chooseFile(
wrapper,
new File(['passport names'], 'source.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
)
await wrapper.find('[data-testid="rooming-list-people-per-room"]').setValue('2')
await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-07-20')
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-22')
await wrapper.find('[data-testid="rooming-list-room-type"]').setValue('DLX')
}
describe('ReservationRoomingListGenerationView', () => {
beforeEach(() => {
vi.mocked(generateReservationRoomingListExcel).mockReset()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:rooming-list'),
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
afterEach(() => {
vi.restoreAllMocks()
if (nativeCreateObjectURL) {
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: nativeCreateObjectURL,
})
} else {
Reflect.deleteProperty(URL, 'createObjectURL')
}
if (nativeRevokeObjectURL) {
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: nativeRevokeObjectURL,
})
} else {
Reflect.deleteProperty(URL, 'revokeObjectURL')
}
})
it('blocks submit until required upload and target fields are provided', async () => {
const wrapper = mountView()
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择来源 Excel 文件。')
expect(wrapper.text()).toContain('每间房人数必须大于 0。')
expect(wrapper.text()).toContain('请填写入住日期。')
expect(wrapper.text()).toContain('请填写离店日期。')
expect(wrapper.text()).toContain('请填写房型。')
})
it('blocks departure dates that are not later than arrival', async () => {
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-20')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('离店日期必须晚于入住日期。')
})
it('submits required and optional fields, then downloads the returned workbook', async () => {
vi.mocked(generateReservationRoomingListExcel).mockResolvedValue({
blob: new Blob(['xlsx-binary'], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
fileName: 'rooming-list-HOTEL-TEST.xlsx',
})
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('[data-testid="rooming-list-title"]').setValue('MR')
await wrapper.find('[data-testid="rooming-list-rate-code"]').setValue('BAR')
await wrapper.find('[data-testid="rooming-list-adults"]').setValue('2')
await wrapper.find('[data-testid="rooming-list-children"]').setValue('0')
await wrapper.find('[data-testid="rooming-list-payment-type"]').setValue('BT')
await wrapper.find('[data-testid="rooming-list-vip"]').setValue('VIP1')
await wrapper.find('[data-testid="rooming-list-nationality"]').setValue('TH')
await wrapper.find('[data-testid="rooming-list-email"]').setValue('guest@example.test')
await wrapper.find('[data-testid="rooming-list-id-type"]').setValue('Passport')
await wrapper.find('[data-testid="rooming-list-id-number"]').setValue('A1234567')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).toHaveBeenCalledWith({
file: expect.any(File),
hotelId: 'HOTEL-TEST',
peoplePerRoom: 2,
arrival: '2026-07-20',
departure: '2026-07-22',
roomType: 'DLX',
title: 'MR',
rateCode: 'BAR',
adults: 2,
children: 0,
paymentType: 'BT',
vip: 'VIP1',
nationality: 'TH',
email: 'guest@example.test',
idType: 'Passport',
idNumber: 'A1234567',
})
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce()
expect(wrapper.text()).toContain('rooming-list-HOTEL-TEST.xlsx')
})
it('does not submit the same source file again while generation is pending', async () => {
let resolveGeneration!: (value: { blob: Blob; fileName: string }) => void
vi.mocked(generateReservationRoomingListExcel).mockImplementation(
() =>
new Promise((resolve) => {
resolveGeneration = resolve
}),
)
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('form').trigger('submit')
await wrapper.find('form').trigger('submit')
expect(generateReservationRoomingListExcel).toHaveBeenCalledTimes(1)
resolveGeneration({
blob: new Blob(['xlsx-binary'], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
fileName: 'rooming-list-HOTEL-TEST.xlsx',
})
await flushPromises()
})
it('shows backend validation details without logging uploaded content', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
vi.mocked(generateReservationRoomingListExcel).mockRejectedValue(
new RoomingListGenerationError('Rooming List 字段校验失败。', 400, 'ROOMING_LIST_VALIDATION_FAILED', [
'file: 未找到表头:护照全名',
]),
)
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Rooming List 字段校验失败,请检查表单内容。')
expect(wrapper.text()).toContain('file: 未找到表头:护照全名')
expect(consoleErrorSpy).not.toHaveBeenCalled()
})
it('shows a permission message for 403 errors', async () => {
vi.mocked(generateReservationRoomingListExcel).mockRejectedValue(
new RoomingListGenerationError('当前用户没有访问该业务能力的权限。', 403, 'FRONTEND_PERMISSION_DENIED', []),
)
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('当前账号没有生成 Rooming List 的权限。')
})
})

View File

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

View File

@@ -0,0 +1,39 @@
export type RoomingListGenerationErrorCode =
| 'ROOMING_LIST_VALIDATION_FAILED'
| 'ROOMING_LIST_SOURCE_FILE_INVALID'
| 'FRONTEND_PERMISSION_DENIED'
| 'HOTEL_ACCESS_DENIED'
| 'AUTH_TOKEN_REQUIRED'
| 'AUTH_SESSION_INVALID'
| 'UNKNOWN'
| (string & {})
export interface RoomingListGenerationInput {
file: File
hotelId?: string | null
peoplePerRoom: number
arrival: string
departure: string
roomType: string
title?: string | null
rateCode?: string | null
adults?: number | null
children?: number | null
paymentType?: string | null
vip?: string | null
nationality?: string | null
email?: string | null
idType?: string | null
idNumber?: string | null
}
export interface RoomingListGenerationDownload {
blob: Blob
fileName: string
}
export interface RoomingListErrorResponse {
error_code?: RoomingListGenerationErrorCode
message?: string
details?: unknown
}

View File

@@ -0,0 +1,862 @@
<template>
<main
class="rooming-list-page"
data-testid="rooming-list-page"
>
<header class="page-header">
<div>
<p class="eyebrow">
{{ t('roomingList.eyebrow') }}
</p>
<h1>{{ t('roomingList.title') }}</h1>
<p class="subtitle">
{{ t('roomingList.subtitle') }}
</p>
</div>
<div class="hotel-context">
<span>{{ t('roomingList.currentHotel') }}</span>
<strong>{{ currentHotelLabel }}</strong>
</div>
</header>
<form
class="generation-form"
@submit.prevent="submitGeneration"
>
<section
class="section-card"
aria-labelledby="rooming-list-source-title"
>
<header class="section-header">
<div class="section-title">
<span class="section-index">01</span>
<h2 id="rooming-list-source-title">
{{ t('roomingList.sourceSection') }}
</h2>
</div>
</header>
<div class="form-grid">
<div class="field field--wide">
<label
for="rooming-list-file"
class="field-label field-label--required"
>
{{ t('roomingList.sourceFile') }}
</label>
<label
class="file-drop"
:class="{ 'is-invalid': Boolean(fieldError('file')) }"
>
<input
id="rooming-list-file"
data-testid="rooming-list-file"
type="file"
accept=".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@change="chooseSourceFile"
>
<i
class="pi pi-file-excel"
aria-hidden="true"
/>
<span>
<strong>{{ sourceFileLabel }}</strong>
<small>{{ t('roomingList.sourceFileHint') }}</small>
</span>
</label>
<p
v-if="fieldError('file')"
:id="fieldErrorId('file')"
class="field-error"
data-testid="rooming-list-file-error"
>
{{ fieldError('file') }}
</p>
</div>
<label class="field">
<span class="field-label field-label--required">{{ t('roomingList.peoplePerRoom') }}</span>
<input
v-model.trim="form.peoplePerRoom"
data-testid="rooming-list-people-per-room"
type="number"
min="1"
step="1"
inputmode="numeric"
:class="validationClass('peoplePerRoom')"
v-bind="validationAria('peoplePerRoom')"
required
>
<span
v-if="fieldError('peoplePerRoom')"
:id="fieldErrorId('peoplePerRoom')"
class="field-error"
>
{{ fieldError('peoplePerRoom') }}
</span>
</label>
<label class="field">
<span class="field-label field-label--required">{{ t('roomingList.roomType') }}</span>
<input
v-model.trim="form.roomType"
data-testid="rooming-list-room-type"
type="text"
autocomplete="off"
:placeholder="t('roomingList.roomTypePlaceholder')"
:class="validationClass('roomType')"
v-bind="validationAria('roomType')"
required
>
<span
v-if="fieldError('roomType')"
:id="fieldErrorId('roomType')"
class="field-error"
>
{{ fieldError('roomType') }}
</span>
</label>
<label class="field">
<span class="field-label field-label--required">{{ t('roomingList.arrival') }}</span>
<input
v-model.trim="form.arrival"
data-testid="rooming-list-arrival"
type="date"
:lang="locale"
:class="validationClass('arrival')"
v-bind="validationAria('arrival')"
required
>
<span
v-if="fieldError('arrival')"
:id="fieldErrorId('arrival')"
class="field-error"
>
{{ fieldError('arrival') }}
</span>
</label>
<label class="field">
<span class="field-label field-label--required">{{ t('roomingList.departure') }}</span>
<input
v-model.trim="form.departure"
data-testid="rooming-list-departure"
type="date"
:lang="locale"
:class="validationClass('departure')"
v-bind="validationAria('departure')"
required
>
<span
v-if="fieldError('departure')"
:id="fieldErrorId('departure')"
class="field-error"
>
{{ fieldError('departure') }}
</span>
</label>
</div>
</section>
<section
class="section-card"
aria-labelledby="rooming-list-target-title"
>
<header class="section-header">
<div class="section-title">
<span class="section-index">02</span>
<h2 id="rooming-list-target-title">
{{ t('roomingList.targetSection') }}
</h2>
</div>
</header>
<div class="form-grid">
<label
v-for="field in optionalFields"
:key="field.key"
class="field"
>
<span class="field-label">{{ t(field.labelKey) }}</span>
<input
v-model.trim="form[field.key]"
:data-testid="field.testId"
:type="field.type"
:min="field.min"
:step="field.step"
autocomplete="off"
:class="validationClass(field.key)"
v-bind="validationAria(field.key)"
>
<span
v-if="fieldError(field.key)"
:id="fieldErrorId(field.key)"
class="field-error"
>
{{ fieldError(field.key) }}
</span>
</label>
</div>
</section>
<section
v-if="messages.length || detailMessages.length || generatedFileName"
class="section-card result-card"
:class="`result-card--${alertTone}`"
aria-live="polite"
>
<header class="section-header">
<div class="section-title">
<span class="section-index">03</span>
<h2>{{ alertTitle }}</h2>
</div>
</header>
<div class="result-body">
<p
v-for="message in messages"
:key="message"
>
{{ message }}
</p>
<dl v-if="generatedFileName">
<div>
<dt>{{ t('roomingList.downloadedFile') }}</dt>
<dd>{{ generatedFileName }}</dd>
</div>
</dl>
<ul
v-if="detailMessages.length"
class="detail-list"
>
<li
v-for="detail in detailMessages"
:key="detail"
>
{{ detail }}
</li>
</ul>
</div>
</section>
<div class="form-actions">
<button
type="submit"
class="generate-button"
data-testid="rooming-list-submit"
:disabled="submitting"
@click.prevent="submitGeneration"
>
<i
class="pi pi-download"
aria-hidden="true"
/>
{{ submitting ? t('roomingList.generating') : t('roomingList.generate') }}
</button>
</div>
</form>
</main>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
generateReservationRoomingListExcel,
RoomingListGenerationError,
} from '@/services/roomingListService'
import { useAuthStore } from '@/stores/authStore'
import type { RoomingListGenerationInput } from '@/types/roomingList'
type FieldErrors = Partial<Record<RoomingListFieldKey, string>>
type AlertTone = 'success' | 'error'
type RoomingListFieldKey =
| 'file'
| 'peoplePerRoom'
| 'arrival'
| 'departure'
| 'roomType'
| 'title'
| 'rateCode'
| 'adults'
| 'children'
| 'paymentType'
| 'vip'
| 'nationality'
| 'email'
| 'idType'
| 'idNumber'
interface OptionalFieldDefinition {
key: Exclude<RoomingListFieldKey, 'file' | 'peoplePerRoom' | 'arrival' | 'departure' | 'roomType'>
labelKey: string
testId: string
type: 'text' | 'email' | 'number'
min?: string
step?: string
}
const { t, te, locale } = useI18n()
const authStore = useAuthStore()
const form = reactive({
file: null as File | null,
peoplePerRoom: '',
arrival: '',
departure: '',
roomType: '',
title: '',
rateCode: '',
adults: '',
children: '',
paymentType: '',
vip: '',
nationality: '',
email: '',
idType: '',
idNumber: '',
})
const submitting = ref(false)
const validationAttempted = ref(false)
const messages = ref<string[]>([])
const detailMessages = ref<string[]>([])
const generatedFileName = ref('')
const alertTone = ref<AlertTone>('error')
const optionalFields: OptionalFieldDefinition[] = [
{ key: 'title', labelKey: 'roomingList.titleColumn', testId: 'rooming-list-title', type: 'text' },
{ key: 'rateCode', labelKey: 'roomingList.rateCode', testId: 'rooming-list-rate-code', type: 'text' },
{ key: 'adults', labelKey: 'roomingList.adults', testId: 'rooming-list-adults', type: 'number', min: '0', step: '1' },
{
key: 'children',
labelKey: 'roomingList.children',
testId: 'rooming-list-children',
type: 'number',
min: '0',
step: '1',
},
{ key: 'paymentType', labelKey: 'roomingList.paymentType', testId: 'rooming-list-payment-type', type: 'text' },
{ key: 'vip', labelKey: 'roomingList.vip', testId: 'rooming-list-vip', type: 'text' },
{ key: 'nationality', labelKey: 'roomingList.nationality', testId: 'rooming-list-nationality', type: 'text' },
{ key: 'email', labelKey: 'roomingList.email', testId: 'rooming-list-email', type: 'email' },
{ key: 'idType', labelKey: 'roomingList.idType', testId: 'rooming-list-id-type', type: 'text' },
{ key: 'idNumber', labelKey: 'roomingList.idNumber', testId: 'rooming-list-id-number', type: 'text' },
]
const currentHotelLabel = computed(() => authStore.selectedHotel?.hotel_name || authStore.selectedHotelId || '-')
const sourceFileLabel = computed(() => form.file?.name || t('roomingList.noFile'))
const alertTitle = computed(() =>
alertTone.value === 'success' ? t('roomingList.resultSuccess') : t('roomingList.validationTitle'),
)
const fieldErrors = computed<FieldErrors>(() => (validationAttempted.value ? collectFieldErrors() : {}))
function chooseSourceFile(event: Event): void {
const input = event.target
if (!(input instanceof HTMLInputElement)) {
return
}
form.file = input.files?.[0] ?? null
clearResult()
}
async function submitGeneration(): Promise<void> {
if (submitting.value) {
return
}
validationAttempted.value = true
clearResult()
const errors = collectFieldErrors()
if (Object.keys(errors).length > 0) {
messages.value = [t('roomingList.errors.FRONTEND_VALIDATION')]
detailMessages.value = Object.values(errors)
return
}
if (!form.file) {
return
}
submitting.value = true
try {
const result = await generateReservationRoomingListExcel(buildGenerationInput(form.file))
downloadBlob(result.blob, result.fileName)
alertTone.value = 'success'
generatedFileName.value = result.fileName
messages.value = [t('roomingList.resultDownloaded')]
} catch (error) {
handleGenerationError(error)
} finally {
submitting.value = false
}
}
function buildGenerationInput(file: File): RoomingListGenerationInput {
return {
file,
hotelId: authStore.selectedHotelId,
peoplePerRoom: Number(form.peoplePerRoom),
arrival: form.arrival,
departure: form.departure,
roomType: form.roomType,
title: form.title,
rateCode: form.rateCode,
adults: optionalIntegerValue(form.adults),
children: optionalIntegerValue(form.children),
paymentType: form.paymentType,
vip: form.vip,
nationality: form.nationality,
email: form.email,
idType: form.idType,
idNumber: form.idNumber,
}
}
function handleGenerationError(error: unknown): void {
alertTone.value = 'error'
if (error instanceof RoomingListGenerationError) {
const messageKey = `roomingList.errors.${error.errorCode}`
messages.value = [te(messageKey) ? t(messageKey) : error.message || t('roomingList.errors.UNKNOWN')]
detailMessages.value = error.details
return
}
messages.value = [t('roomingList.errors.UNKNOWN')]
detailMessages.value = []
}
function downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob)
const revokeObjectUrl = URL.revokeObjectURL.bind(URL)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.append(link)
link.click()
link.remove()
window.setTimeout(() => revokeObjectUrl(url), 0)
}
function clearResult(): void {
messages.value = []
detailMessages.value = []
generatedFileName.value = ''
alertTone.value = 'error'
}
function collectFieldErrors(): FieldErrors {
const errors: FieldErrors = {}
if (!form.file) {
errors.file = t('roomingList.fieldErrors.fileRequired')
} else if (!isExcelFileName(form.file.name)) {
errors.file = t('roomingList.fieldErrors.fileType')
}
if (!isPositiveInteger(form.peoplePerRoom)) {
errors.peoplePerRoom = t('roomingList.fieldErrors.positiveInteger')
}
if (!isValidDateValue(form.arrival)) {
errors.arrival = form.arrival ? t('roomingList.fieldErrors.dateFormat') : t('roomingList.fieldErrors.arrivalRequired')
}
if (!isValidDateValue(form.departure)) {
errors.departure = form.departure
? t('roomingList.fieldErrors.dateFormat')
: t('roomingList.fieldErrors.departureRequired')
}
if (isValidDateValue(form.arrival) && isValidDateValue(form.departure) && form.departure <= form.arrival) {
errors.departure = t('roomingList.fieldErrors.departureAfterArrival')
}
if (!form.roomType.trim()) {
errors.roomType = t('roomingList.fieldErrors.roomTypeRequired')
}
if (form.adults && !isNonNegativeInteger(form.adults)) {
errors.adults = t('roomingList.fieldErrors.nonNegativeInteger')
}
if (form.children && !isNonNegativeInteger(form.children)) {
errors.children = t('roomingList.fieldErrors.nonNegativeInteger')
}
if (form.email && !isValidEmail(form.email)) {
errors.email = t('roomingList.fieldErrors.invalidEmail')
}
return errors
}
function fieldError(fieldKey: RoomingListFieldKey): string {
return fieldErrors.value[fieldKey] ?? ''
}
function fieldErrorId(fieldKey: RoomingListFieldKey): string {
return `rooming-list-${fieldKey}-error`
}
function validationClass(fieldKey: RoomingListFieldKey): Record<string, boolean> {
return {
'is-invalid': Boolean(fieldError(fieldKey)),
}
}
function validationAria(fieldKey: RoomingListFieldKey): Record<string, string | undefined> {
if (!fieldError(fieldKey)) {
return {
'aria-invalid': undefined,
'aria-describedby': undefined,
}
}
return {
'aria-invalid': 'true',
'aria-describedby': fieldErrorId(fieldKey),
}
}
function optionalIntegerValue(value: string): number | null {
const normalized = normalizeInputText(value)
if (!normalized) {
return null
}
return Number(normalized)
}
function isExcelFileName(fileName: string): boolean {
return /\.(xlsx|xls)$/i.test(fileName.trim())
}
function isPositiveInteger(value: unknown): boolean {
return /^[1-9]\d*$/.test(normalizeInputText(value))
}
function isNonNegativeInteger(value: unknown): boolean {
return /^(0|[1-9]\d*)$/.test(normalizeInputText(value))
}
function isValidDateValue(value: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
const yearText = match?.[1]
const monthText = match?.[2]
const dayText = match?.[3]
if (!yearText || !monthText || !dayText) {
return false
}
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
const date = new Date(year, month - 1, day)
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
}
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeInputText(value))
}
function normalizeInputText(value: unknown): string {
if (typeof value === 'string') {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return ''
}
</script>
<style scoped>
.rooming-list-page {
width: min(100%, 1180px);
margin: 0 auto;
color: var(--th-color-navy-950);
}
.page-header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: end;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--th-color-blue-600);
font-size: 12px;
font-weight: 900;
}
.page-header h1 {
margin: 0;
font-size: 30px;
line-height: 1.15;
}
.subtitle {
max-width: 720px;
margin: 8px 0 0;
color: var(--th-color-slate-500);
font-size: 13px;
line-height: 1.55;
}
.hotel-context {
display: grid;
justify-items: end;
gap: 4px;
flex: 0 0 auto;
color: var(--th-color-slate-500);
font-size: 12px;
}
.hotel-context strong {
color: var(--th-color-navy-950);
font-size: 15px;
}
.generation-form {
display: grid;
gap: 18px;
}
.section-card {
min-width: 0;
overflow: hidden;
border: 1px solid var(--th-color-border);
border-radius: 8px;
background: var(--th-color-white);
box-shadow: var(--th-shadow-panel);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 20px 16px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
}
.section-title h2 {
margin: 0;
font-size: 18px;
}
.section-index {
color: var(--th-color-blue-600);
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
border-top: 1px solid var(--th-color-border);
padding: 20px;
}
.field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
}
.field--wide {
grid-column: 1 / -1;
}
.field-label {
color: var(--th-color-slate-600);
font-size: 12px;
font-weight: 800;
}
.field-label--required::after {
content: "*";
margin-left: 4px;
color: var(--th-color-danger);
font-weight: 900;
}
.field input {
width: 100%;
min-width: 0;
min-height: 42px;
border: 1px solid #b9c7dc;
border-radius: 6px;
background: #fbfdff;
color: var(--th-color-navy-950);
font: inherit;
font-weight: 700;
padding: 10px 11px;
outline: none;
box-shadow: 0 1px 2px rgb(15 23 42 / 5%), inset 0 0 0 1px rgb(15 23 42 / 3%);
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
.field input:hover {
border-color: #8fa4c2;
}
.field input:focus-visible,
.file-drop:focus-within,
.generate-button:focus-visible {
outline: 3px solid rgb(18 103 255 / 24%);
outline-offset: 2px;
}
.field input.is-invalid,
.file-drop.is-invalid {
border-color: var(--th-color-danger);
background: #fff7f9;
box-shadow: 0 0 0 3px rgb(206 43 63 / 12%), inset 0 0 0 1px rgb(206 43 63 / 8%);
}
.field-error {
color: #be123c;
font-size: 11px;
font-weight: 800;
line-height: 1.45;
}
.file-drop {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12px;
min-height: 78px;
border: 1px dashed #9eb2d0;
border-radius: 8px;
background: var(--th-color-slate-50);
padding: 16px;
cursor: pointer;
}
.file-drop input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.file-drop i {
color: var(--th-color-success);
font-size: 24px;
}
.file-drop strong,
.file-drop small {
display: block;
overflow-wrap: anywhere;
}
.file-drop strong {
color: var(--th-color-navy-950);
font-size: 14px;
}
.file-drop small {
margin-top: 4px;
color: var(--th-color-slate-500);
font-size: 12px;
}
.result-card--success {
border-color: rgb(21 152 95 / 35%);
}
.result-card--error {
border-color: rgb(206 43 63 / 25%);
}
.result-body {
display: grid;
gap: 12px;
border-top: 1px solid var(--th-color-border);
padding: 18px 20px 20px;
}
.result-body p {
margin: 0;
color: var(--th-color-slate-700);
font-weight: 700;
}
.result-body dl {
display: grid;
gap: 8px;
margin: 0;
}
.result-body dl div {
display: flex;
justify-content: space-between;
gap: 16px;
border-top: 1px solid var(--th-color-border);
padding-top: 8px;
}
.result-body dt {
color: var(--th-color-slate-500);
}
.result-body dd {
margin: 0;
font-weight: 800;
overflow-wrap: anywhere;
}
.detail-list {
margin: 0;
padding-left: 18px;
color: #be123c;
font-size: 12px;
font-weight: 700;
}
.form-actions {
display: flex;
justify-content: flex-end;
border: 1px solid var(--th-color-border);
border-radius: 8px;
background: var(--th-color-white);
padding: 14px;
}
.generate-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
border: 0;
border-radius: 6px;
background: var(--th-color-blue-600);
color: var(--th-color-white);
font: inherit;
font-weight: 900;
padding: 0 18px;
cursor: pointer;
}
.generate-button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
@media (max-width: 760px) {
.page-header {
align-items: start;
flex-direction: column;
}
.hotel-context {
justify-items: start;
}
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>