实现 Rooming List CP2 字段收口

This commit is contained in:
andy
2026-07-22 15:29:33 +07:00
parent 71191a10b0
commit 1236df86cf
24 changed files with 704 additions and 481 deletions

View File

@@ -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;