接入V4房型信息展示模型

This commit is contained in:
andy committed 2026-07-21 09:04:13 +07:00
1 parent 1d593f05fd
commit 8d181ee8ad
21 files changed
+1390 -77

No files matched your search

@@ -0,0 +1,536 @@
<template>
<div class="room-information">
<div
v-if="!roomInformation"
class="room-information__empty"
>
{{ t('taskV4.roomInformation.noDisplayModel') }}
</div>
<template v-else>
<div class="room-information__chips">
<span>{{ eventType || '-' }}</span>
<span>{{ bookingType || '-' }}</span>
</div>
<section
v-if="showChangeSummary"
class="room-information__panel room-information__panel--changes"
>
<h3>{{ t('taskV4.roomInformation.changeSummary') }}</h3>
<ul>
<li
v-for="(change, index) in visibleChangeSummary"
:key="`${change.field}-${index}`"
>
<span>{{ roomInformationFieldLabel(change.field) }}</span>
<strong>{{ formatRoomInformationValue(change.field, change.before, currentValues) }}</strong>
<em aria-hidden="true">→</em>
<strong>{{ formatRoomInformationValue(change.field, change.after, finalValues) }}</strong>
</li>
</ul>
</section>
<section
v-if="showCurrentValues"
class="room-information__panel"
>
<h3>{{ t('taskV4.roomInformation.currentValues') }}</h3>
<dl
v-if="scalarEntries(currentValues).length"
class="room-information__grid"
>
<template
v-for="entry in scalarEntries(currentValues)"
:key="entry.key"
>
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
<dd>{{ entry.value }}</dd>
</template>
</dl>
<p
v-else-if="!roomItems(currentValues).length"
class="room-information__empty"
>
{{ t('taskV4.roomInformation.emptyValues') }}
</p>
<div
v-if="roomItems(currentValues).length"
class="room-information__room-items"
>
<h4>{{ t('taskV4.roomInformation.roomItems') }}</h4>
<table>
<thead>
<tr>
<th>{{ t('taskV4.roomInformation.roomTypeCode') }}</th>
<th>{{ t('taskV4.roomInformation.roomCount') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in roomItems(currentValues)"
:key="index"
>
<td>{{ formatRoomInformationValue('room_type_code', item.room_type_code, item) }}</td>
<td>{{ formatRoomInformationValue('room_count', item.room_count, item) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="room-information__panel">
<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
v-if="showEditableFields"
:fields="visibleFields"
:model-value="modelValue"
:read-only="readOnly"
:editable-keys="editableKeys"
:validation-errors="validationErrors"
:hotel-id="hotelId"
@update:model-value="emit('update:modelValue', $event)"
/>
<template v-else>
<dl
v-if="scalarEntries(finalValues).length"
class="room-information__grid"
>
<template
v-for="entry in scalarEntries(finalValues)"
:key="entry.key"
>
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
<dd>{{ entry.value }}</dd>
</template>
</dl>
<p
v-else-if="!roomItems(finalValues).length"
class="room-information__empty"
>
{{ t('taskV4.roomInformation.emptyValues') }}
</p>
<div
v-if="roomItems(finalValues).length"
class="room-information__room-items"
>
<h4>{{ t('taskV4.roomInformation.roomItems') }}</h4>
<table>
<thead>
<tr>
<th>{{ t('taskV4.roomInformation.roomTypeCode') }}</th>
<th>{{ t('taskV4.roomInformation.roomCount') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in roomItems(finalValues)"
:key="index"
>
<td>{{ formatRoomInformationValue('room_type_code', item.room_type_code, item) }}</td>
<td>{{ formatRoomInformationValue('room_count', item.room_count, item) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import type {
ReservationRecord,
ReservationV4RoomInformationDisplayModel,
ReservationV4RoomInformationValues,
ReservationV4TaskCardResult,
} from '@/types/reservation'
import {
reservationV4FieldKey,
isReservationV4RoomInformationSafeField,
stringifyReservationV4SafeDisplayValue,
} from '@/utils/reservationV4FieldRules'
const props = withDefaults(defineProps<{
card: ReservationV4TaskCardResult
modelValue: ReservationRecord
readOnly: boolean
editableKeys: string[]
validationErrors?: Record<string, string>
hotelId?: string
}>(), {
validationErrors: () => ({}),
hotelId: undefined,
})
const emit = defineEmits<{
'update:modelValue': [value: ReservationRecord]
}>()
const { t } = useI18n()
const roomInformation = computed<ReservationV4RoomInformationDisplayModel | null>(() => {
const payload = props.card.display_payload
const value = isRecord(payload?.room_information) ? payload.room_information : null
return value as ReservationV4RoomInformationDisplayModel | null
})
const eventType = computed(() => roomInformation.value?.event_type ?? props.card.event_type ?? '')
const bookingType = computed(() => roomInformation.value?.booking_type ?? '')
const currentValues = computed(() => normalizeValues(roomInformation.value?.current_values))
const finalValues = computed(() => normalizeValues(roomInformation.value?.final_values))
const changeSummary = computed(() => roomInformation.value?.change_summary ?? [])
const visibleChangeSummary = computed(() => changeSummary.value.filter((change) =>
isVisibleRoomInformationChangeField(change.field),
))
const visibleFields = computed(() => props.card.fields.filter((field) =>
isReservationV4RoomInformationSafeField(field) && isVisibleRoomInformationField(field),
))
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
const showCurrentValues = computed(() =>
['CANCEL_BOOKING'].includes(eventType.value) && hasVisibleValues(currentValues.value),
)
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && visibleFields.value.length > 0)
const showReadonlyBreakfast = computed(() =>
hasValue(finalValues.value.breakfast_included) &&
(bookingType.value === 'GROUP' || !hasEditableField('/room_information/final_values/breakfast_included')),
)
const scalarFieldOrder = [
'group_block_name',
'fit_name',
'arrival_date',
'departure_date',
'nights',
'rate_code',
'breakfast_included',
'group_booking_status',
'block_id',
'confirmation_number',
]
function scalarEntries(values: ReservationV4RoomInformationValues): Array<{ key: string; value: string }> {
return scalarFieldOrder
.filter((key) => key in values && hasValue(values[key]))
.filter((key) => key !== 'group_booking_status_label')
.filter((key) => key !== 'breakfast_included' || !hasEditableField('/room_information/final_values/breakfast_included'))
.map((key) => ({
key,
value: formatRoomInformationValue(key, values[key], values),
}))
}
function roomItems(values: ReservationV4RoomInformationValues): ReservationRecord[] {
return Array.isArray(values.room_items)
? values.room_items.filter((item): item is ReservationRecord => isRecord(item))
: []
}
function hasEditableField(pointer: string): boolean {
return visibleFields.value.some((field) => reservationV4FieldKey(field) === pointer)
}
function isVisibleRoomInformationField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
const key = reservationV4FieldKey(field)
return !(bookingType.value === 'GROUP' && key === '/room_information/final_values/breakfast_included')
}
function isVisibleRoomInformationChangeField(field: unknown): boolean {
const key = typeof field === 'string' ? field.toLowerCase() : ''
return key !== 'adult' &&
key !== 'adults' &&
!key.endsWith('.adult') &&
!key.endsWith('.adults') &&
!key.endsWith('/adult') &&
!key.endsWith('/adults') &&
!key.includes('target_order') &&
!key.includes('locator_value')
}
function hasVisibleValues(values: ReservationV4RoomInformationValues): boolean {
return scalarEntries(values).length > 0 || roomItems(values).length > 0
}
function normalizeValues(value: unknown): ReservationV4RoomInformationValues {
return isRecord(value) ? value : {}
}
function roomInformationFieldLabel(field: unknown): string {
const key = typeof field === 'string' ? field : ''
const labelKey = roomInformationLabelKeys[key]
return labelKey ? t(labelKey) : key || '-'
}
function formatRoomInformationValue(
field: string,
value: unknown,
values: ReservationRecord,
): string {
if (field === 'group_booking_status') {
const statusLabel = values.group_booking_status_label
if (typeof statusLabel === 'string' && statusLabel.trim()) {
return statusLabel
}
}
if (typeof value === 'boolean') {
return value ? t('taskV4.roomInformation.yes') : t('taskV4.roomInformation.no')
}
return stringifyReservationV4SafeDisplayValue(
value,
t('taskV4.field.empty'),
t('taskV4.field.hidden'),
)
}
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim().length > 0
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
function isRecord(value: unknown): value is ReservationRecord {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
const roomInformationLabelKeys: Record<string, string> = {
group_block_name: 'taskV4.roomInformation.groupBlockName',
fit_name: 'taskV4.roomInformation.fitName',
arrival_date: 'taskV4.roomInformation.arrivalDate',
departure_date: 'taskV4.roomInformation.departureDate',
nights: 'taskV4.roomInformation.nights',
rate_code: 'taskV4.roomInformation.rateCode',
breakfast_included: 'taskV4.roomInformation.breakfastIncluded',
group_booking_status: 'taskV4.roomInformation.groupBookingStatus',
block_id: 'taskV4.roomInformation.blockId',
confirmation_number: 'taskV4.roomInformation.confirmationNumber',
room_items: 'taskV4.roomInformation.roomItems',
room_type_code: 'taskV4.roomInformation.roomTypeCode',
room_count: 'taskV4.roomInformation.roomCount',
}
</script>
<style scoped>
.room-information {
display: grid;
gap: 16px;
}
.room-information__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.room-information__chips span {
border-radius: 999px;
background: var(--th-color-info-bg);
color: var(--th-color-blue-700);
font-size: 12px;
font-weight: 800;
padding: 4px 9px;
}
.room-information__panel {
display: grid;
gap: 12px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 14px;
}
.room-information__panel h3,
.room-information__room-items h4 {
margin: 0;
color: var(--th-color-slate-900);
font-size: 14px;
font-weight: 900;
}
.room-information__panel--changes {
border-color: color-mix(in srgb, var(--th-color-blue-600) 28%, var(--th-color-slate-200));
background: linear-gradient(180deg, var(--th-color-white), var(--th-color-info-bg));
}
.room-information__panel--changes ul {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
}
.room-information__panel--changes li {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1fr) auto minmax(0, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
color: var(--th-color-slate-700);
font-size: 13px;
list-style: none;
}
.room-information__panel--changes strong {
color: var(--th-color-slate-900);
overflow-wrap: anywhere;
}
.room-information__panel--changes em {
color: var(--th-color-blue-600);
font-style: normal;
font-weight: 900;
}
.room-information__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
margin: 0;
}
.room-information__grid dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.room-information__grid dd {
margin: 4px 0 0;
color: var(--th-color-slate-900);
font-size: 14px;
font-weight: 800;
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 {
display: grid;
gap: 8px;
}
.room-information__room-items table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
font-size: 13px;
}
.room-information__room-items th,
.room-information__room-items td {
border-bottom: 1px solid var(--th-color-slate-200);
padding: 9px 10px;
text-align: left;
}
.room-information__room-items th {
background: var(--th-color-slate-50);
color: var(--th-color-slate-500);
font-weight: 900;
}
.room-information__room-items td {
color: var(--th-color-slate-900);
font-weight: 800;
}
.room-information__room-items tr:last-child td {
border-bottom: 0;
}
.room-information__empty {
margin: 0;
color: var(--th-color-slate-500);
font-size: 13px;
font-weight: 700;
}
@media (max-width: 760px) {
.room-information__grid,
.room-information__panel--changes li {
grid-template-columns: 1fr;
}
}
</style>
@@ -15,6 +15,7 @@
<select
v-if="isEditable(field) && isSelectField(field)"
class="v4-field__control"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:disabled="selectDisabled(field)"
:value="fieldValue(field)"
@@ -34,15 +35,27 @@
v-else-if="isEditable(field) && isDateField(field)"
class="v4-field__control"
type="date"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isEditable(field) && isCheckboxField(field)"
class="v4-field__control v4-field__control--checkbox"
type="checkbox"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:checked="checkboxValue(field)"
@change="updateCheckboxField(field, $event)"
>
<input
v-else-if="isEditable(field) && isNumberField(field)"
class="v4-field__control"
type="number"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
@@ -51,6 +64,7 @@
<textarea
v-else-if="isEditable(field) && isTextAreaField(field)"
class="v4-field__control v4-field__control--textarea"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
rows="3"
@@ -61,16 +75,26 @@
v-else-if="isEditable(field)"
class="v4-field__control"
type="text"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isCheckboxField(field)"
class="v4-field__control v4-field__control--checkbox"
type="checkbox"
:name="fieldKey(field)"
:checked="checkboxValue(field)"
disabled
>
<span
v-else
class="v4-field__static"
>
{{ stringifyV4Value(fieldValue(field)) }}
{{ staticFieldValue(field) }}
</span>
<small
@@ -209,13 +233,18 @@ function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return isSelectControl(field) && Boolean(reservationV4LookupKindForOptionsSource(field.options_source))
return isSelectControl(field) &&
(Boolean(reservationV4LookupKindForOptionsSource(field.options_source)) || fixedFieldOptions(field).length > 0)
}
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'DATE'
}
function isCheckboxField(field: ReservationV4TaskCardFieldResult): boolean {
return ['CHECKBOX', 'BOOLEAN'].includes(normalizeControlType(field.control_type))
}
function isNumberField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'NUMBER'
}
@@ -232,7 +261,7 @@ function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
label: lookupItemLabel(item),
item,
}))
: []
: fixedFieldOptions(field)
const exactItem = exactLookupItemForField(field)
if (exactItem && !options.some((option) => option.value === exactItem.code)) {
options.push({
@@ -267,6 +296,17 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
return stringifyV4Value(value)
}
function staticFieldValue(field: ReservationV4TaskCardFieldResult): string {
const value = fieldValue(field)
const matchedOption = fieldOptions(field).find((option) => option.value === value)
return matchedOption?.label ?? stringifyV4Value(value)
}
function checkboxValue(field: ReservationV4TaskCardFieldResult): boolean {
const value = readV4FieldValue(field, props.modelValue)
return value === true || value === 'true' || value === 1 || value === '1'
}
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
delete invalidLookupValues[reservationV4FieldKey(field)]
@@ -276,6 +316,15 @@ function updateField(field: ReservationV4TaskCardFieldResult, event: Event): voi
})
}
function updateCheckboxField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement
delete invalidLookupValues[reservationV4FieldKey(field)]
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.checked,
})
}
function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
@@ -389,6 +438,27 @@ function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
return ['SELECT', 'LOOKUP'].includes(normalizeControlType(field.control_type))
}
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const optionsSource = normalizeControlType(field.options_source)
if (optionsSource !== 'RESERVATION_V4_GROUP_BOOKING_STATUS_FIXED') {
return []
}
return [
{
value: 'TEN',
label: 'TEN-Tentative',
},
{
value: 'DEF',
label: 'DEF-Definite',
},
{
value: 'INQ',
label: 'INQ-Inquiry',
},
]
}
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
if (item.display_name && item.display_name !== item.code) {
return `${item.code} - ${item.display_name}`
@@ -629,6 +699,14 @@ function exactLookupKey(kind: ReservationV4LookupKind, code: string): string {
resize: vertical;
}
.v4-field__control--checkbox {
min-height: 18px;
width: 18px;
accent-color: var(--th-color-blue-600);
box-shadow: none;
padding: 0;
}
.v4-field__control[aria-invalid='true'] {
border-color: var(--th-color-danger);
box-shadow: 0 0 0 3px var(--th-color-danger-bg);
@@ -1,5 +1,8 @@
<template>
<section class="th-section task-card-section">
<section
class="th-section task-card-section"
:data-testid="isRoomInformationCard ? 'room-information-card' : undefined"
>
<div class="task-card-section__header">
<div class="task-card-section__heading">
<h2 class="th-section-title">
@@ -31,7 +34,19 @@
</div>
</div>
<ReservationV4RoomInformationCard
v-if="isRoomInformationCard"
class="task-card-section__fields"
:card="card"
:model-value="modelValue"
:read-only="readOnly"
:editable-keys="editableKeys"
:validation-errors="validationErrors"
:hotel-id="hotelId"
@update:model-value="emit('update:modelValue', $event)"
/>
<ReservationV4TaskCardFieldRenderer
v-else
class="task-card-section__fields"
:fields="card.fields"
:model-value="modelValue"
@@ -43,7 +58,7 @@
/>
<details
v-if="safePayloadRows.length"
v-if="!isRoomInformationCard && safePayloadRows.length"
class="safe-payload"
>
<summary>
@@ -86,6 +101,7 @@
<label>
<span>{{ t('taskV4.confirmedOrderId') }}</span>
<input
name="v4_confirmed_order_id"
:value="reviewForm.confirmed_order_id"
:placeholder="t('taskV4.confirmedOrderIdPlaceholder')"
@input="updateReviewForm('confirmed_order_id', $event)"
@@ -107,7 +123,7 @@
:disabled="!canReview"
@click="emit('resolveReview')"
>
{{ submitting ? t('taskV4.resolvingReview') : t('taskV4.resolveReview') }}
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
</button>
<RouterLink
v-if="boundOrderId"
@@ -148,6 +164,7 @@ import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
@@ -198,6 +215,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
const availabilityReason = computed(() => {
return props.card.availability.readonly_reason_message ??