优化预订事项卡片布局
This commit is contained in:
@@ -7,9 +7,64 @@
|
|||||||
{{ t('taskV4.roomInformation.noDisplayModel') }}
|
{{ t('taskV4.roomInformation.noDisplayModel') }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="room-information__chips">
|
<div class="room-information__header">
|
||||||
<span>{{ eventTypeLabel }}</span>
|
<div class="room-information__chips">
|
||||||
<span>{{ bookingTypeLabel }}</span>
|
<span>{{ eventTypeLabel }}</span>
|
||||||
|
<span>{{ bookingTypeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showGroupBookingStatusSlot"
|
||||||
|
class="room-information__status-slot"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-if="groupBookingStatusEditable && groupBookingStatusField"
|
||||||
|
class="room-information__status-field"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ t('taskV4.roomInformation.groupBookingStatus') }}
|
||||||
|
<sup v-if="groupBookingStatusRequired">*</sup>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name="/room_information/final_values/group_booking_status"
|
||||||
|
:aria-invalid="groupBookingStatusErrors.length ? 'true' : 'false'"
|
||||||
|
:value="groupBookingStatusValue"
|
||||||
|
@change="updateGroupBookingStatus"
|
||||||
|
>
|
||||||
|
<option value="">{{ t('taskV4.field.selectPlaceholder') }}</option>
|
||||||
|
<option
|
||||||
|
v-for="option in groupBookingStatusOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small
|
||||||
|
v-for="error in groupBookingStatusErrors"
|
||||||
|
:key="error"
|
||||||
|
class="room-information__status-error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="room-information__status-badge"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ t('taskV4.roomInformation.groupBookingStatus') }}
|
||||||
|
<sup v-if="groupBookingStatusRequired">*</sup>
|
||||||
|
</span>
|
||||||
|
<strong>{{ groupBookingStatusLabel }}</strong>
|
||||||
|
<small
|
||||||
|
v-for="error in groupBookingStatusErrors"
|
||||||
|
:key="error"
|
||||||
|
class="room-information__status-error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -128,38 +183,9 @@
|
|||||||
|
|
||||||
<section class="room-information__panel">
|
<section class="room-information__panel">
|
||||||
<h3>{{ t('taskV4.roomInformation.finalValues') }}</h3>
|
<h3>{{ t('taskV4.roomInformation.finalValues') }}</h3>
|
||||||
<div class="room-information__derived">
|
|
||||||
<div
|
|
||||||
v-if="hasValue(finalValues.nights)"
|
|
||||||
class="room-information__derived-item"
|
|
||||||
>
|
|
||||||
<span>{{ t('taskV4.roomInformation.nights') }}</span>
|
|
||||||
<strong>{{ formatRoomInformationValue('nights', finalValues.nights, finalValues) }}</strong>
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
v-if="showReadonlyBreakfast"
|
|
||||||
class="room-information__derived-item room-information__derived-item--checkbox"
|
|
||||||
>
|
|
||||||
<span>{{ t('taskV4.roomInformation.breakfastIncluded') }}</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="/room_information/final_values/breakfast_included"
|
|
||||||
:checked="finalValues.breakfast_included === true"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
v-if="hasValue(finalValues.group_booking_status) && !hasEditableField('/room_information/final_values/group_booking_status')"
|
|
||||||
class="room-information__derived-item"
|
|
||||||
>
|
|
||||||
<span>{{ t('taskV4.roomInformation.groupBookingStatus') }}</span>
|
|
||||||
<strong>{{ formatRoomInformationValue('group_booking_status', finalValues.group_booking_status, finalValues) }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReservationV4TaskCardFieldRenderer
|
<ReservationV4TaskCardFieldRenderer
|
||||||
v-if="showEditableFields"
|
v-if="showEditableFields"
|
||||||
:fields="visibleFields"
|
:fields="finalDisplayFields"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:read-only="readOnly"
|
:read-only="readOnly"
|
||||||
:editable-keys="editableKeys"
|
:editable-keys="editableKeys"
|
||||||
@@ -169,11 +195,11 @@
|
|||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<dl
|
<dl
|
||||||
v-if="scalarEntries(finalValues).length"
|
v-if="finalScalarEntries.length"
|
||||||
class="room-information__grid"
|
class="room-information__grid"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="entry in scalarEntries(finalValues)"
|
v-for="entry in finalScalarEntries"
|
||||||
:key="entry.key"
|
:key="entry.key"
|
||||||
>
|
>
|
||||||
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
|
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
|
||||||
@@ -225,10 +251,13 @@ import type {
|
|||||||
ReservationV4RoomInformationDisplayModel,
|
ReservationV4RoomInformationDisplayModel,
|
||||||
ReservationV4RoomInformationValues,
|
ReservationV4RoomInformationValues,
|
||||||
ReservationV4TaskCardResult,
|
ReservationV4TaskCardResult,
|
||||||
|
ReservationV4TaskCardFieldResult,
|
||||||
} from '@/types/reservation'
|
} from '@/types/reservation'
|
||||||
import {
|
import {
|
||||||
reservationV4FieldKey,
|
reservationV4FieldKey,
|
||||||
isReservationV4RoomInformationSafeField,
|
isReservationV4RoomInformationSafeField,
|
||||||
|
isReservationV4EditableField,
|
||||||
|
readV4FieldValue,
|
||||||
stringifyReservationV4SafeDisplayValue,
|
stringifyReservationV4SafeDisplayValue,
|
||||||
} from '@/utils/reservationV4FieldRules'
|
} from '@/utils/reservationV4FieldRules'
|
||||||
|
|
||||||
@@ -287,9 +316,87 @@ const changeSummary = computed(() => roomInformation.value?.change_summary ?? []
|
|||||||
const visibleChangeSummary = computed(() => changeSummary.value.filter((change) =>
|
const visibleChangeSummary = computed(() => changeSummary.value.filter((change) =>
|
||||||
isVisibleRoomInformationChangeField(change.field),
|
isVisibleRoomInformationChangeField(change.field),
|
||||||
))
|
))
|
||||||
const visibleFields = computed(() => props.card.fields.filter((field) =>
|
type FieldOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomInformationFields = computed(() => props.card.fields.filter((field) =>
|
||||||
isReservationV4RoomInformationSafeField(field) && isVisibleRoomInformationField(field),
|
isReservationV4RoomInformationSafeField(field) && isVisibleRoomInformationField(field),
|
||||||
))
|
))
|
||||||
|
const groupBookingStatusField = computed(() =>
|
||||||
|
roomInformationFields.value.find((field) => roomInformationFieldKey(field) === 'group_booking_status') ?? null,
|
||||||
|
)
|
||||||
|
const groupBookingStatusEditable = computed(() => {
|
||||||
|
const field = groupBookingStatusField.value
|
||||||
|
return Boolean(
|
||||||
|
field &&
|
||||||
|
!props.readOnly &&
|
||||||
|
props.editableKeys.includes(reservationV4FieldKey(field)) &&
|
||||||
|
isReservationV4EditableField(field),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const groupBookingStatusRequired = computed(() => groupBookingStatusField.value?.required === true)
|
||||||
|
const groupBookingStatusErrors = computed(() => {
|
||||||
|
const field = groupBookingStatusField.value
|
||||||
|
if (!field) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const key = reservationV4FieldKey(field)
|
||||||
|
return [
|
||||||
|
props.validationErrors[key],
|
||||||
|
...(field.validation_errors ?? []),
|
||||||
|
].filter((message): message is string => Boolean(message?.trim()))
|
||||||
|
})
|
||||||
|
const groupBookingStatusOptions = computed<FieldOption[]>(() => {
|
||||||
|
const backendOptions = (groupBookingStatusField.value?.fixed_options ?? [])
|
||||||
|
.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label || option.value,
|
||||||
|
}))
|
||||||
|
if (backendOptions.length) {
|
||||||
|
return backendOptions
|
||||||
|
}
|
||||||
|
const displayOptions = roomInformation.value?.group_booking_status_options ?? []
|
||||||
|
if (displayOptions.length) {
|
||||||
|
return displayOptions.map((option) => ({
|
||||||
|
value: option.code,
|
||||||
|
label: option.label || option.code,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: 'TEN',
|
||||||
|
label: 'TEN-Tentative',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'DEF',
|
||||||
|
label: 'DEF-Definite',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INQ',
|
||||||
|
label: 'INQ-Inquiry',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const groupBookingStatusValue = computed(() => {
|
||||||
|
const field = groupBookingStatusField.value
|
||||||
|
const value = field ? readV4FieldValue(field, props.modelValue) : finalValues.value.group_booking_status
|
||||||
|
return typeof value === 'string' ? value : stringifyRoomInformationValue(value)
|
||||||
|
})
|
||||||
|
const groupBookingStatusLabel = computed(() => {
|
||||||
|
const value = groupBookingStatusValue.value
|
||||||
|
const matchedOption = groupBookingStatusOptions.value.find((option) => option.value === value)
|
||||||
|
return matchedOption?.label ?? formatRoomInformationValue('group_booking_status', value, finalValues.value)
|
||||||
|
})
|
||||||
|
const showGroupBookingStatusSlot = computed(() =>
|
||||||
|
Boolean(groupBookingStatusField.value) || hasValue(finalValues.value.group_booking_status),
|
||||||
|
)
|
||||||
|
const finalDisplayFields = computed(() => buildFinalDisplayFields())
|
||||||
|
const finalScalarEntries = computed(() => scalarEntries(finalValues.value, {
|
||||||
|
hideEditableBreakfast: false,
|
||||||
|
includeIdentifierFallbacks: true,
|
||||||
|
}))
|
||||||
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
|
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
|
||||||
const showCurrentValues = computed(() =>
|
const showCurrentValues = computed(() =>
|
||||||
['UPDATE_BOOKING', 'CANCEL_BOOKING'].includes(eventType.value) &&
|
['UPDATE_BOOKING', 'CANCEL_BOOKING'].includes(eventType.value) &&
|
||||||
@@ -299,11 +406,7 @@ const showProposedValues = computed(() =>
|
|||||||
eventType.value === 'UPDATE_BOOKING' &&
|
eventType.value === 'UPDATE_BOOKING' &&
|
||||||
hasVisibleValues(proposedValues.value, { hideEditableBreakfast: false }),
|
hasVisibleValues(proposedValues.value, { hideEditableBreakfast: false }),
|
||||||
)
|
)
|
||||||
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && visibleFields.value.length > 0)
|
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && finalDisplayFields.value.length > 0)
|
||||||
const showReadonlyBreakfast = computed(() =>
|
|
||||||
hasValue(finalValues.value.breakfast_included) &&
|
|
||||||
(bookingType.value === 'GROUP' || !hasEditableField('/room_information/final_values/breakfast_included')),
|
|
||||||
)
|
|
||||||
|
|
||||||
const scalarFieldOrder = [
|
const scalarFieldOrder = [
|
||||||
'group_block_name',
|
'group_block_name',
|
||||||
@@ -312,19 +415,31 @@ const scalarFieldOrder = [
|
|||||||
'departure_date',
|
'departure_date',
|
||||||
'nights',
|
'nights',
|
||||||
'rate_code',
|
'rate_code',
|
||||||
'breakfast_included',
|
|
||||||
'group_booking_status',
|
|
||||||
'block_id',
|
|
||||||
'confirmation_number',
|
'confirmation_number',
|
||||||
|
'block_id',
|
||||||
|
'breakfast_included',
|
||||||
|
]
|
||||||
|
const displayFieldOrder = [
|
||||||
|
'group_block_name',
|
||||||
|
'fit_name',
|
||||||
|
'arrival_date',
|
||||||
|
'departure_date',
|
||||||
|
'nights',
|
||||||
|
'rate_code',
|
||||||
|
'confirmation_number',
|
||||||
|
'block_id',
|
||||||
|
'room_items',
|
||||||
|
'breakfast_included',
|
||||||
]
|
]
|
||||||
|
|
||||||
function scalarEntries(
|
function scalarEntries(
|
||||||
values: ReservationV4RoomInformationValues,
|
values: ReservationV4RoomInformationValues,
|
||||||
options: { hideEditableBreakfast?: boolean } = {},
|
options: { hideEditableBreakfast?: boolean; includeIdentifierFallbacks?: boolean } = {},
|
||||||
): Array<{ key: string; value: string }> {
|
): Array<{ key: string; value: string }> {
|
||||||
const hideEditableBreakfast = options.hideEditableBreakfast ?? true
|
const hideEditableBreakfast = options.hideEditableBreakfast ?? true
|
||||||
|
const includeIdentifierFallbacks = options.includeIdentifierFallbacks ?? false
|
||||||
return scalarFieldOrder
|
return scalarFieldOrder
|
||||||
.filter((key) => key in values && hasValue(values[key]))
|
.filter((key) => hasValue(values[key]) || (includeIdentifierFallbacks && isRequiredDisplayIdentifier(key)))
|
||||||
.filter((key) => key !== 'group_booking_status_label')
|
.filter((key) => key !== 'group_booking_status_label')
|
||||||
.filter((key) =>
|
.filter((key) =>
|
||||||
key !== 'breakfast_included' ||
|
key !== 'breakfast_included' ||
|
||||||
@@ -333,7 +448,7 @@ function scalarEntries(
|
|||||||
)
|
)
|
||||||
.map((key) => ({
|
.map((key) => ({
|
||||||
key,
|
key,
|
||||||
value: formatRoomInformationValue(key, values[key], values),
|
value: hasValue(values[key]) ? formatRoomInformationValue(key, values[key], values) : '-',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,12 +459,15 @@ function roomItems(values: ReservationV4RoomInformationValues): ReservationRecor
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasEditableField(pointer: string): boolean {
|
function hasEditableField(pointer: string): boolean {
|
||||||
return visibleFields.value.some((field) => reservationV4FieldKey(field) === pointer)
|
return roomInformationFields.value.some((field) =>
|
||||||
|
reservationV4FieldKey(field) === pointer && isReservationV4EditableField(field),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleRoomInformationField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
|
function isVisibleRoomInformationField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
|
||||||
const key = reservationV4FieldKey(field)
|
const key = normalizeFieldKey(reservationV4FieldKey(field))
|
||||||
return !(bookingType.value === 'GROUP' && key === '/room_information/final_values/breakfast_included')
|
return !(bookingType.value === 'GROUP' && key === '/room_information/final_values/breakfast_included') &&
|
||||||
|
isVisibleRoomInformationChangeField(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleRoomInformationChangeField(field: unknown): boolean {
|
function isVisibleRoomInformationChangeField(field: unknown): boolean {
|
||||||
@@ -371,6 +489,103 @@ function hasVisibleValues(
|
|||||||
return scalarEntries(values, options).length > 0 || roomItems(values).length > 0
|
return scalarEntries(values, options).length > 0 || roomItems(values).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFinalDisplayFields(): ReservationV4TaskCardFieldResult[] {
|
||||||
|
const sourceFields = roomInformationFields.value.filter((field) =>
|
||||||
|
!isHeaderStatusField(field) &&
|
||||||
|
!isReadOnlyFinalPresentationField(field) &&
|
||||||
|
!(bookingType.value === 'GROUP' && roomInformationFieldKey(field) === 'breakfast_included'),
|
||||||
|
)
|
||||||
|
const usedFields = new Set<ReservationV4TaskCardFieldResult>()
|
||||||
|
const result = displayFieldOrder.flatMap((key) => {
|
||||||
|
if (key === 'nights') {
|
||||||
|
return displayReadonlyFieldIfAvailable(key, finalValues.value.nights)
|
||||||
|
}
|
||||||
|
if (isRequiredDisplayIdentifier(key)) {
|
||||||
|
return [createReadonlyDisplayField(key, hasValue(finalValues.value[key]) ? finalValues.value[key] : '-')]
|
||||||
|
}
|
||||||
|
if (key === 'breakfast_included' && bookingType.value === 'GROUP') {
|
||||||
|
return displayReadonlyFieldIfAvailable(key, finalValues.value.breakfast_included, 'CHECKBOX')
|
||||||
|
}
|
||||||
|
const matchedFields = sourceFields.filter((field) => fieldMatchesDisplayKey(field, key))
|
||||||
|
matchedFields.forEach((field) => usedFields.add(field))
|
||||||
|
return matchedFields
|
||||||
|
})
|
||||||
|
const remainingFields = sourceFields.filter((field) => !usedFields.has(field))
|
||||||
|
return [...result, ...remainingFields]
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayReadonlyFieldIfAvailable(
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
controlType: string = 'READONLY',
|
||||||
|
): ReservationV4TaskCardFieldResult[] {
|
||||||
|
return hasValue(value) ? [createReadonlyDisplayField(key, value, controlType)] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadonlyDisplayField(
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
controlType: string = 'READONLY',
|
||||||
|
): ReservationV4TaskCardFieldResult {
|
||||||
|
return {
|
||||||
|
field_path: `room_information.final_values.${key}`,
|
||||||
|
field_pointer: `/room_information/final_values/${key}`,
|
||||||
|
display_name: roomInformationFieldLabel(key),
|
||||||
|
value,
|
||||||
|
editable: false,
|
||||||
|
required: false,
|
||||||
|
control_type: controlType,
|
||||||
|
edit_scope: 'NEVER',
|
||||||
|
write_target: 'NONE',
|
||||||
|
options_source: null,
|
||||||
|
fixed_options: null,
|
||||||
|
raw_readonly: true,
|
||||||
|
validation_errors: [],
|
||||||
|
control_hint: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldMatchesDisplayKey(field: ReservationV4TaskCardFieldResult, key: string): boolean {
|
||||||
|
if (key === 'room_items') {
|
||||||
|
return normalizeFieldKey(reservationV4FieldKey(field)).includes('/room_items/')
|
||||||
|
}
|
||||||
|
return roomInformationFieldKey(field) === key
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHeaderStatusField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
return roomInformationFieldKey(field) === 'group_booking_status'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReadOnlyFinalPresentationField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
return ['nights', 'confirmation_number', 'block_id'].includes(roomInformationFieldKey(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequiredDisplayIdentifier(key: string): boolean {
|
||||||
|
return key === 'confirmation_number' || key === 'block_id'
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomInformationFieldKey(field: ReservationV4TaskCardFieldResult): string {
|
||||||
|
const normalizedKey = normalizeFieldKey(reservationV4FieldKey(field))
|
||||||
|
const segments = normalizedKey.split('/').filter(Boolean)
|
||||||
|
return segments[segments.length - 1] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFieldKey(value: string): string {
|
||||||
|
return value.replace(/\./g, '/').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroupBookingStatus(event: Event): void {
|
||||||
|
const field = groupBookingStatusField.value
|
||||||
|
if (!field) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLSelectElement
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
[reservationV4FieldKey(field)]: target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeValues(value: unknown): ReservationV4RoomInformationValues {
|
function normalizeValues(value: unknown): ReservationV4RoomInformationValues {
|
||||||
return isRecord(value) ? value : {}
|
return isRecord(value) ? value : {}
|
||||||
}
|
}
|
||||||
@@ -402,6 +617,13 @@ function formatRoomInformationValue(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringifyRoomInformationValue(value: unknown): string {
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
function hasValue(value: unknown): boolean {
|
function hasValue(value: unknown): boolean {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return false
|
return false
|
||||||
@@ -442,6 +664,13 @@ const roomInformationLabelKeys: Record<string, string> = {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-information__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.room-information__chips {
|
.room-information__chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -457,6 +686,65 @@ const roomInformationLabelKeys: Record<string, string> = {
|
|||||||
padding: 4px 9px;
|
padding: 4px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-information__status-slot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: min(260px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-badge,
|
||||||
|
.room-information__status-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 180px;
|
||||||
|
border: 1px solid var(--th-color-slate-200);
|
||||||
|
border-radius: var(--th-radius-sm);
|
||||||
|
background: var(--th-color-slate-50);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-badge span,
|
||||||
|
.room-information__status-field span {
|
||||||
|
color: var(--th-color-slate-500);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-badge sup,
|
||||||
|
.room-information__status-field sup {
|
||||||
|
color: var(--th-color-danger);
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-badge strong {
|
||||||
|
color: var(--th-color-slate-900);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-field select {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid var(--th-color-slate-200);
|
||||||
|
border-radius: var(--th-radius-sm);
|
||||||
|
background: var(--th-color-white);
|
||||||
|
color: var(--th-color-slate-900);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-field select:focus {
|
||||||
|
border-color: var(--th-color-blue-600);
|
||||||
|
box-shadow: 0 0 0 3px var(--th-color-info-bg);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-error {
|
||||||
|
color: var(--th-color-danger);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.room-information__panel {
|
.room-information__panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -534,49 +822,6 @@ const roomInformationLabelKeys: Record<string, string> = {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-information__derived {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-information__derived-item {
|
|
||||||
display: inline-grid;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 120px;
|
|
||||||
border: 1px solid var(--th-color-slate-200);
|
|
||||||
border-radius: var(--th-radius-sm);
|
|
||||||
background: var(--th-color-slate-50);
|
|
||||||
padding: 9px 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-information__derived-item span {
|
|
||||||
color: var(--th-color-slate-500);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-information__derived-item strong {
|
|
||||||
color: var(--th-color-slate-900);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-information__derived-item--checkbox {
|
|
||||||
align-items: center;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-information__derived-item--checkbox span {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-information__derived-item--checkbox input {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
accent-color: var(--th-color-blue-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-information__room-items {
|
.room-information__room-items {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -621,6 +866,15 @@ const roomInformationLabelKeys: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.room-information__header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-information__status-slot {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.room-information__grid,
|
.room-information__grid,
|
||||||
.room-information__panel--changes li {
|
.room-information__panel--changes li {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -11,15 +11,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="task-card-section__status-actions">
|
<div class="task-card-section__status-actions">
|
||||||
<ReservationStatusBadge :status="card.card_status" />
|
<ReservationStatusBadge :status="card.card_status" />
|
||||||
<button
|
|
||||||
v-if="showPrimaryAction"
|
|
||||||
type="button"
|
|
||||||
class="primary-button"
|
|
||||||
:disabled="primaryActionDisabled"
|
|
||||||
@click="handlePrimaryAction"
|
|
||||||
>
|
|
||||||
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,15 +118,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showOrderLink"
|
v-if="showOrderLink || showPrimaryAction"
|
||||||
class="card-secondary-actions"
|
class="task-card-section__footer"
|
||||||
>
|
>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
v-if="showOrderLink"
|
||||||
class="card-order-link"
|
class="card-order-link"
|
||||||
:to="`/reservation/orders/${boundOrderId}`"
|
:to="`/reservation/orders/${boundOrderId}`"
|
||||||
>
|
>
|
||||||
{{ t('task.viewOrder') }}
|
{{ t('task.viewOrder') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<div
|
||||||
|
v-if="showPrimaryAction"
|
||||||
|
class="task-card-section__footer-actions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary-button"
|
||||||
|
:disabled="primaryActionDisabled"
|
||||||
|
@click="handlePrimaryAction"
|
||||||
|
>
|
||||||
|
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -327,13 +332,20 @@ function handlePrimaryAction(): void {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-secondary-actions {
|
.task-card-section__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 0 20px 20px;
|
padding: 0 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-card-section__footer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.review-box {
|
.review-box {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||||
@@ -419,14 +431,19 @@ function handlePrimaryAction(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-card-section__header,
|
.task-card-section__header,
|
||||||
.card-secondary-actions {
|
.task-card-section__footer {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card-section__status-actions {
|
.task-card-section__status-actions,
|
||||||
|
.task-card-section__footer-actions {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-card-section__footer {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import type { DOMWrapper } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -38,6 +39,10 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
|
|||||||
|
|
||||||
const service = await import('@/services/reservationService')
|
const service = await import('@/services/reservationService')
|
||||||
|
|
||||||
|
function findFieldByLabel(wrapper: DOMWrapper<Element>, label: string): DOMWrapper<Element> | undefined {
|
||||||
|
return wrapper.findAll('.v4-field').find((field) => field.find('.v4-field__label').text().includes(label))
|
||||||
|
}
|
||||||
|
|
||||||
describe('reservation V4 pages', () => {
|
describe('reservation V4 pages', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
@@ -125,7 +130,7 @@ describe('reservation V4 pages', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps task card layout classes and places the primary card action in the card header', async () => {
|
it('keeps task card layout classes and places the primary card action in the card footer', async () => {
|
||||||
const detail = createOrderTaskDetail()
|
const detail = createOrderTaskDetail()
|
||||||
detail.business_cards[0]!.card_type = 'VOUCHER'
|
detail.business_cards[0]!.card_type = 'VOUCHER'
|
||||||
detail.business_cards[0]!.display_payload = {
|
detail.business_cards[0]!.display_payload = {
|
||||||
@@ -144,7 +149,13 @@ describe('reservation V4 pages', () => {
|
|||||||
expect(wrapper.find('.task-card-section__header').exists()).toBe(true)
|
expect(wrapper.find('.task-card-section__header').exists()).toBe(true)
|
||||||
expect(wrapper.find('.card-meta-grid').exists()).toBe(false)
|
expect(wrapper.find('.card-meta-grid').exists()).toBe(false)
|
||||||
expect(wrapper.find('details.safe-payload').exists()).toBe(false)
|
expect(wrapper.find('details.safe-payload').exists()).toBe(false)
|
||||||
expect(wrapper.find('.task-card-section__header .primary-button').text()).toContain(zhCN.taskV4.confirmCard)
|
expect(wrapper.find('.task-card-section__header .primary-button').exists()).toBe(false)
|
||||||
|
const firstCard = wrapper.find('.task-card-section')
|
||||||
|
const footer = firstCard.find('.task-card-section__footer')
|
||||||
|
expect(footer.exists()).toBe(true)
|
||||||
|
const cardChildren = Array.from(firstCard.element.children)
|
||||||
|
expect(cardChildren[cardChildren.length - 1]).toBe(footer.element)
|
||||||
|
expect(footer.find('.task-card-section__footer-actions .primary-button').text()).toContain(zhCN.taskV4.confirmCard)
|
||||||
expect(wrapper.find('.card-actions .primary-button').exists()).toBe(false)
|
expect(wrapper.find('.card-actions .primary-button').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -758,6 +769,26 @@ describe('reservation V4 pages', () => {
|
|||||||
value: 'LEGACY',
|
value: 'LEGACY',
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
const groupStatusField = detail.business_cards[0]!.fields.find((field) =>
|
||||||
|
field.field_pointer === '/room_information/final_values/group_booking_status',
|
||||||
|
)
|
||||||
|
if (groupStatusField) {
|
||||||
|
groupStatusField.validation_errors = ['请选择团队预订状态']
|
||||||
|
groupStatusField.fixed_options = [
|
||||||
|
{
|
||||||
|
value: 'TEN',
|
||||||
|
label: 'TEN-Tentative',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'DEF',
|
||||||
|
label: 'DEF-Definite',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INQ',
|
||||||
|
label: 'INQ-Inquiry',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
|
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
|
||||||
...detail,
|
...detail,
|
||||||
@@ -780,6 +811,22 @@ describe('reservation V4 pages', () => {
|
|||||||
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.finalValues)
|
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.finalValues)
|
||||||
expect(roomCard.text()).toContain('TEN-Tentative')
|
expect(roomCard.text()).toContain('TEN-Tentative')
|
||||||
expect(roomCard.text()).toContain('3')
|
expect(roomCard.text()).toContain('3')
|
||||||
|
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.confirmationNumber)
|
||||||
|
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.blockId)
|
||||||
|
const confirmationField = findFieldByLabel(roomCard, zhCN.taskV4.roomInformation.confirmationNumber)
|
||||||
|
const blockIdField = findFieldByLabel(roomCard, zhCN.taskV4.roomInformation.blockId)
|
||||||
|
expect(confirmationField?.find('.v4-field__static').text()).toBe('-')
|
||||||
|
expect(blockIdField?.find('.v4-field__static').text()).toBe('-')
|
||||||
|
const statusSlot = roomCard.find('.room-information__status-slot')
|
||||||
|
const statusSelect = statusSlot.find('select[name="/room_information/final_values/group_booking_status"]')
|
||||||
|
expect(statusSlot.text()).toContain(zhCN.taskV4.roomInformation.groupBookingStatus)
|
||||||
|
expect(statusSlot.text()).toContain('*')
|
||||||
|
expect(statusSlot.text()).toContain('TEN-Tentative')
|
||||||
|
expect(statusSelect.attributes('aria-invalid')).toBe('true')
|
||||||
|
expect(statusSelect.findAll('option').map((option) => (option.element as HTMLOptionElement).value))
|
||||||
|
.toEqual(['', 'TEN', 'DEF', 'INQ'])
|
||||||
|
expect(roomCard.find('.room-information__status-error').text()).toContain('请选择团队预订状态')
|
||||||
|
expect(roomCard.find('.room-information__derived').exists()).toBe(false)
|
||||||
expect(roomCard.text()).not.toContain('target_order')
|
expect(roomCard.text()).not.toContain('target_order')
|
||||||
expect(roomCard.text()).not.toContain('Adult')
|
expect(roomCard.text()).not.toContain('Adult')
|
||||||
expect(roomCard.text()).not.toContain('Legacy Room Type')
|
expect(roomCard.text()).not.toContain('Legacy Room Type')
|
||||||
@@ -801,6 +848,13 @@ describe('reservation V4 pages', () => {
|
|||||||
expect(roomCard.find('input[name="/room_information/final_values/departure_date"]').attributes('type')).toBe('text')
|
expect(roomCard.find('input[name="/room_information/final_values/departure_date"]').attributes('type')).toBe('text')
|
||||||
expect((roomCard.find('input[name="/room_information/final_values/departure_date"]').element as HTMLInputElement).value)
|
expect((roomCard.find('input[name="/room_information/final_values/departure_date"]').element as HTMLInputElement).value)
|
||||||
.toBe('2026-07-29')
|
.toBe('2026-07-29')
|
||||||
|
const roomInformationText = roomCard.text()
|
||||||
|
expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.departureDate))
|
||||||
|
.toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.nights))
|
||||||
|
expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.nights))
|
||||||
|
.toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.rateCode))
|
||||||
|
expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.blockId))
|
||||||
|
.toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.breakfastIncluded))
|
||||||
|
|
||||||
await roomCard.find('input[name="/room_information/final_values/group_block_name"]').setValue('前端修正团队名')
|
await roomCard.find('input[name="/room_information/final_values/group_block_name"]').setValue('前端修正团队名')
|
||||||
await roomCard.find('select[name="/room_information/final_values/group_booking_status"]').setValue('DEF')
|
await roomCard.find('select[name="/room_information/final_values/group_booking_status"]').setValue('DEF')
|
||||||
@@ -1303,9 +1357,10 @@ describe('reservation V4 pages', () => {
|
|||||||
|
|
||||||
it('keeps the per-card primary action right aligned on narrow screens', () => {
|
it('keeps the per-card primary action right aligned on narrow screens', () => {
|
||||||
expect(taskCardSectionSource).toContain('@media (max-width: 980px)')
|
expect(taskCardSectionSource).toContain('@media (max-width: 980px)')
|
||||||
expect(taskCardSectionSource).toContain('.task-card-section__status-actions')
|
expect(taskCardSectionSource).toContain('.task-card-section__footer-actions')
|
||||||
expect(taskCardSectionSource).toContain('align-self: stretch')
|
expect(taskCardSectionSource).toContain('padding: 0 20px 20px')
|
||||||
expect(taskCardSectionSource).toContain('justify-content: flex-end')
|
expect(taskCardSectionSource).toContain('justify-content: flex-end')
|
||||||
|
expect(taskCardSectionSource).toContain('.task-card-section__footer {\n align-items: flex-end;\n }')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires confirmed order id before resolving an unresolved V4 review card', async () => {
|
it('requires confirmed order id before resolving an unresolved V4 review card', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user