实现 Rooming List CP2 字段收口
This commit is contained in:
@@ -398,7 +398,8 @@ export default {
|
||||
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.',
|
||||
subtitle:
|
||||
'Upload the travel group list, enter people per room, room type, payment type and nationality. Arrival and departure are derived from the source travel dates.',
|
||||
currentHotel: 'Current hotel',
|
||||
sourceSection: 'Source list and required fields',
|
||||
targetSection: 'Target Excel defaults',
|
||||
@@ -415,8 +416,12 @@ export default {
|
||||
adults: 'Adults',
|
||||
children: 'Children',
|
||||
paymentType: 'Payment Type',
|
||||
paymentTypeCa: 'CA',
|
||||
vip: 'VIP',
|
||||
nationality: 'Nationality',
|
||||
nationalityPlaceholder: 'Select nationality',
|
||||
nationalityKr: 'KR',
|
||||
nationalityChn: 'CHN',
|
||||
email: 'Email',
|
||||
idType: 'ID Type',
|
||||
idNumber: 'ID Number',
|
||||
@@ -436,6 +441,10 @@ export default {
|
||||
dateFormat: 'Date format must be yyyy-MM-dd.',
|
||||
departureAfterArrival: 'Departure must be later than arrival.',
|
||||
roomTypeRequired: 'Enter room type.',
|
||||
paymentTypeRequired: 'Select payment type.',
|
||||
paymentTypeAllowed: 'Only CA is currently allowed.',
|
||||
nationalityRequired: 'Select nationality.',
|
||||
nationalityAllowed: 'Only KR or CHN are currently allowed.',
|
||||
invalidEmail: 'Invalid email.',
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -398,7 +398,8 @@ export default {
|
||||
roomingList: {
|
||||
eyebrow: 'เครื่องมือ Reservation',
|
||||
title: 'สร้าง Rooming List Excel',
|
||||
subtitle: 'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้องและค่าเริ่มต้น แล้วดาวน์โหลดไฟล์ Excel ที่สร้างแล้ว',
|
||||
subtitle:
|
||||
'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality โดย Arrival/Departure จะมาจาก Travel Date ในไฟล์ต้นทาง',
|
||||
currentHotel: 'โรงแรมปัจจุบัน',
|
||||
sourceSection: 'ไฟล์รายชื่อและข้อมูลจำเป็น',
|
||||
targetSection: 'ค่าเริ่มต้นของ Excel ปลายทาง',
|
||||
@@ -415,8 +416,12 @@ export default {
|
||||
adults: 'Adults',
|
||||
children: 'Children',
|
||||
paymentType: 'Payment Type',
|
||||
paymentTypeCa: 'CA',
|
||||
vip: 'VIP',
|
||||
nationality: 'Nationality',
|
||||
nationalityPlaceholder: 'เลือก Nationality',
|
||||
nationalityKr: 'KR',
|
||||
nationalityChn: 'CHN',
|
||||
email: 'Email',
|
||||
idType: 'ID Type',
|
||||
idNumber: 'ID Number',
|
||||
@@ -436,6 +441,10 @@ export default {
|
||||
dateFormat: 'รูปแบบวันที่ต้องเป็น yyyy-MM-dd',
|
||||
departureAfterArrival: 'Departure ต้องอยู่หลัง Arrival',
|
||||
roomTypeRequired: 'กรุณากรอก Room Type',
|
||||
paymentTypeRequired: 'กรุณาเลือก Payment Type',
|
||||
paymentTypeAllowed: 'รองรับเฉพาะ CA ในตอนนี้',
|
||||
nationalityRequired: 'กรุณาเลือก Nationality',
|
||||
nationalityAllowed: 'รองรับเฉพาะ KR หรือ CHN ในตอนนี้',
|
||||
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -398,7 +398,7 @@ export default {
|
||||
roomingList: {
|
||||
eyebrow: '预订工具',
|
||||
title: '生成 Rooming List Excel',
|
||||
subtitle: '上传旅行团名单 Excel,填写每房人数和目标列默认值,生成酒店可导入的 Rooming List。',
|
||||
subtitle: '上传旅行团名单 Excel,填写每房人数、房型、付款方式和国籍;入住/离店日期由来源表的旅游日期生成。',
|
||||
currentHotel: '当前酒店',
|
||||
sourceSection: '来源名单与必填信息',
|
||||
targetSection: '目标 Excel 默认值',
|
||||
@@ -415,8 +415,12 @@ export default {
|
||||
adults: '成人数(Adults)',
|
||||
children: '儿童数(Children)',
|
||||
paymentType: '付款方式(Payment Type)',
|
||||
paymentTypeCa: 'CA',
|
||||
vip: 'VIP',
|
||||
nationality: '国籍(Nationality)',
|
||||
nationalityPlaceholder: '请选择国籍',
|
||||
nationalityKr: 'KR',
|
||||
nationalityChn: 'CHN',
|
||||
email: '邮箱(Email)',
|
||||
idType: '证件类型(ID Type)',
|
||||
idNumber: '证件号码(ID Number)',
|
||||
@@ -436,6 +440,10 @@ export default {
|
||||
dateFormat: '日期格式必须为 yyyy-MM-dd。',
|
||||
departureAfterArrival: '离店日期必须晚于入住日期。',
|
||||
roomTypeRequired: '请填写房型。',
|
||||
paymentTypeRequired: '请选择付款方式。',
|
||||
paymentTypeAllowed: '付款方式当前只允许 CA。',
|
||||
nationalityRequired: '请选择国籍。',
|
||||
nationalityAllowed: '国籍当前只允许 KR 或 CHN。',
|
||||
invalidEmail: 'Email 格式不正确。',
|
||||
},
|
||||
errors: {
|
||||
|
||||
@@ -72,19 +72,9 @@ function buildRoomingListForm(input: RoomingListGenerationInput): 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
|
||||
}
|
||||
|
||||
@@ -95,12 +85,6 @@ function appendOptionalText(form: FormData, key: string, value: string | null |
|
||||
}
|
||||
}
|
||||
|
||||
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')) {
|
||||
|
||||
@@ -15,19 +15,9 @@ function createInput(): RoomingListGenerationInput {
|
||||
}),
|
||||
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',
|
||||
paymentType: 'CA',
|
||||
nationality: 'CHN',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,19 +74,19 @@ describe('roomingListService', () => {
|
||||
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(formData.get('payment_type')).toBe('CA')
|
||||
expect(formData.get('nationality')).toBe('CHN')
|
||||
expect(formData.get('arrival')).toBeNull()
|
||||
expect(formData.get('departure')).toBeNull()
|
||||
expect(formData.get('title')).toBeNull()
|
||||
expect(formData.get('rate_code')).toBeNull()
|
||||
expect(formData.get('adults')).toBeNull()
|
||||
expect(formData.get('children')).toBeNull()
|
||||
expect(formData.get('vip')).toBeNull()
|
||||
expect(formData.get('email')).toBeNull()
|
||||
expect(formData.get('id_type')).toBeNull()
|
||||
expect(formData.get('id_number')).toBeNull()
|
||||
expect(result.fileName).toBe('rooming-list-HOTEL-TEST-20260718.xlsx')
|
||||
expect(result.blob.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
})
|
||||
@@ -107,16 +97,8 @@ describe('roomingListService', () => {
|
||||
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')
|
||||
|
||||
@@ -84,9 +84,8 @@ async function fillRequiredFields(wrapper: ReturnType<typeof mountView>) {
|
||||
}),
|
||||
)
|
||||
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')
|
||||
await wrapper.find('[data-testid="rooming-list-nationality"]').setValue('CHN')
|
||||
}
|
||||
|
||||
describe('ReservationRoomingListGenerationView', () => {
|
||||
@@ -132,24 +131,26 @@ describe('ReservationRoomingListGenerationView', () => {
|
||||
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('请填写房型。')
|
||||
expect(wrapper.text()).toContain('请选择国籍。')
|
||||
})
|
||||
|
||||
it('blocks departure dates that are not later than arrival', async () => {
|
||||
it('does not render manually entered dates or removed target defaults', () => {
|
||||
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('离店日期必须晚于入住日期。')
|
||||
expect(wrapper.find('[data-testid="rooming-list-arrival"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-departure"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-title"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-rate-code"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-adults"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-children"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-vip"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-email"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-id-type"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="rooming-list-id-number"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('submits required and optional fields, then downloads the returned workbook', async () => {
|
||||
it('submits required fields and allowed defaults, then downloads the returned workbook', async () => {
|
||||
vi.mocked(generateReservationRoomingListExcel).mockResolvedValue({
|
||||
blob: new Blob(['xlsx-binary'], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -158,16 +159,6 @@ describe('ReservationRoomingListGenerationView', () => {
|
||||
})
|
||||
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()
|
||||
@@ -176,19 +167,9 @@ describe('ReservationRoomingListGenerationView', () => {
|
||||
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',
|
||||
paymentType: 'CA',
|
||||
nationality: 'CHN',
|
||||
})
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -12,19 +12,9 @@ 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
|
||||
nationality: string
|
||||
}
|
||||
|
||||
export interface RoomingListGenerationDownload {
|
||||
|
||||
@@ -116,46 +116,6 @@
|
||||
{{ 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>
|
||||
|
||||
@@ -173,28 +133,55 @@
|
||||
</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)"
|
||||
<label class="field">
|
||||
<span class="field-label">{{ t('roomingList.paymentType') }}</span>
|
||||
<select
|
||||
v-model="form.paymentType"
|
||||
data-testid="rooming-list-payment-type"
|
||||
:class="validationClass('paymentType')"
|
||||
v-bind="validationAria('paymentType')"
|
||||
>
|
||||
<option value="CA">
|
||||
{{ t('roomingList.paymentTypeCa') }}
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
v-if="fieldError(field.key)"
|
||||
:id="fieldErrorId(field.key)"
|
||||
v-if="fieldError('paymentType')"
|
||||
:id="fieldErrorId('paymentType')"
|
||||
class="field-error"
|
||||
>
|
||||
{{ fieldError(field.key) }}
|
||||
{{ fieldError('paymentType') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label field-label--required">{{ t('roomingList.nationality') }}</span>
|
||||
<select
|
||||
v-model="form.nationality"
|
||||
data-testid="rooming-list-nationality"
|
||||
:class="validationClass('nationality')"
|
||||
v-bind="validationAria('nationality')"
|
||||
required
|
||||
>
|
||||
<option
|
||||
disabled
|
||||
value=""
|
||||
>
|
||||
{{ t('roomingList.nationalityPlaceholder') }}
|
||||
</option>
|
||||
<option value="KR">
|
||||
{{ t('roomingList.nationalityKr') }}
|
||||
</option>
|
||||
<option value="CHN">
|
||||
{{ t('roomingList.nationalityChn') }}
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
v-if="fieldError('nationality')"
|
||||
:id="fieldErrorId('nationality')"
|
||||
class="field-error"
|
||||
>
|
||||
{{ fieldError('nationality') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -274,48 +261,22 @@ 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 allowedPaymentTypes = new Set(['CA'])
|
||||
const allowedNationalities = new Set(['KR', 'CHN'])
|
||||
|
||||
const { t, te, locale } = useI18n()
|
||||
const { t, te } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
file: null as File | null,
|
||||
peoplePerRoom: '',
|
||||
arrival: '',
|
||||
departure: '',
|
||||
roomType: '',
|
||||
title: '',
|
||||
rateCode: '',
|
||||
adults: '',
|
||||
children: '',
|
||||
paymentType: '',
|
||||
vip: '',
|
||||
paymentType: 'CA',
|
||||
nationality: '',
|
||||
email: '',
|
||||
idType: '',
|
||||
idNumber: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
@@ -325,26 +286,6 @@ 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(() =>
|
||||
@@ -396,19 +337,9 @@ function buildGenerationInput(file: File): RoomingListGenerationInput {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,28 +384,18 @@ function collectFieldErrors(): FieldErrors {
|
||||
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.paymentType.trim()) {
|
||||
errors.paymentType = t('roomingList.fieldErrors.paymentTypeRequired')
|
||||
} else if (!allowedPaymentTypes.has(form.paymentType.trim())) {
|
||||
errors.paymentType = t('roomingList.fieldErrors.paymentTypeAllowed')
|
||||
}
|
||||
if (form.children && !isNonNegativeInteger(form.children)) {
|
||||
errors.children = t('roomingList.fieldErrors.nonNegativeInteger')
|
||||
}
|
||||
if (form.email && !isValidEmail(form.email)) {
|
||||
errors.email = t('roomingList.fieldErrors.invalidEmail')
|
||||
if (!form.nationality.trim()) {
|
||||
errors.nationality = t('roomingList.fieldErrors.nationalityRequired')
|
||||
} else if (!allowedNationalities.has(form.nationality.trim())) {
|
||||
errors.nationality = t('roomingList.fieldErrors.nationalityAllowed')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -506,14 +427,6 @@ function validationAria(fieldKey: RoomingListFieldKey): Record<string, string |
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -522,29 +435,6 @@ 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()
|
||||
@@ -678,7 +568,8 @@ function normalizeInputText(value: unknown): string {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.field input {
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
@@ -694,11 +585,13 @@ function normalizeInputText(value: unknown): string {
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
.field input:hover {
|
||||
.field input:hover,
|
||||
.field select:hover {
|
||||
border-color: #8fa4c2;
|
||||
}
|
||||
|
||||
.field input:focus-visible,
|
||||
.field select:focus-visible,
|
||||
.file-drop:focus-within,
|
||||
.generate-button:focus-visible {
|
||||
outline: 3px solid rgb(18 103 255 / 24%);
|
||||
@@ -706,6 +599,7 @@ function normalizeInputText(value: unknown): string {
|
||||
}
|
||||
|
||||
.field input.is-invalid,
|
||||
.field select.is-invalid,
|
||||
.file-drop.is-invalid {
|
||||
border-color: var(--th-color-danger);
|
||||
background: #fff7f9;
|
||||
|
||||
Reference in New Issue
Block a user