实现V4 Trace事项卡前端展示
This commit is contained in:
@@ -439,6 +439,13 @@ function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
|
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
|
||||||
|
const backendOptions = (field.fixed_options ?? []).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label || option.value,
|
||||||
|
}))
|
||||||
|
if (backendOptions.length) {
|
||||||
|
return backendOptions
|
||||||
|
}
|
||||||
const optionsSource = normalizeControlType(field.options_source)
|
const optionsSource = normalizeControlType(field.options_source)
|
||||||
if (optionsSource !== 'RESERVATION_V4_GROUP_BOOKING_STATUS_FIXED') {
|
if (optionsSource !== 'RESERVATION_V4_GROUP_BOOKING_STATUS_FIXED') {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -58,6 +58,16 @@
|
|||||||
:card="card"
|
:card="card"
|
||||||
:source-message-id="sourceMessageId"
|
:source-message-id="sourceMessageId"
|
||||||
/>
|
/>
|
||||||
|
<ReservationV4TraceCard
|
||||||
|
v-else-if="isTraceCard"
|
||||||
|
: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
|
<ReservationV4TaskCardFieldRenderer
|
||||||
v-else
|
v-else
|
||||||
class="task-card-section__fields"
|
class="task-card-section__fields"
|
||||||
@@ -72,7 +82,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<details
|
<details
|
||||||
v-if="!isRoomInformationCard && !isRoomingListCard && safePayloadRows.length"
|
v-if="!isRoomInformationCard && !isRoomingListCard && !isTraceCard && safePayloadRows.length"
|
||||||
class="safe-payload"
|
class="safe-payload"
|
||||||
>
|
>
|
||||||
<summary>
|
<summary>
|
||||||
@@ -124,6 +134,7 @@
|
|||||||
<label>
|
<label>
|
||||||
<span>{{ t('taskV4.reviewReason') }}</span>
|
<span>{{ t('taskV4.reviewReason') }}</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
name="v4_review_reason"
|
||||||
:value="reviewForm.reason"
|
:value="reviewForm.reason"
|
||||||
:placeholder="t('taskV4.reviewReasonPlaceholder')"
|
:placeholder="t('taskV4.reviewReasonPlaceholder')"
|
||||||
rows="3"
|
rows="3"
|
||||||
@@ -182,6 +193,7 @@ import ReservationV4PaymentAttachmentPreview from '@/components/reservation/Rese
|
|||||||
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
|
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
|
||||||
import ReservationV4RoomingListCard from '@/components/reservation/ReservationV4RoomingListCard.vue'
|
import ReservationV4RoomingListCard from '@/components/reservation/ReservationV4RoomingListCard.vue'
|
||||||
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
|
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
|
||||||
|
import ReservationV4TraceCard from '@/components/reservation/ReservationV4TraceCard.vue'
|
||||||
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
|
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
|
||||||
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
|
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
|
||||||
import {
|
import {
|
||||||
@@ -242,6 +254,7 @@ const { t } = useI18n()
|
|||||||
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
|
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
|
||||||
const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT')
|
const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT')
|
||||||
const isRoomingListCard = computed(() => props.card.card_type === 'ROOMING_LIST')
|
const isRoomingListCard = computed(() => props.card.card_type === 'ROOMING_LIST')
|
||||||
|
const isTraceCard = computed(() => props.card.card_type === 'TRACE_RESERVATION_NOTES')
|
||||||
const visibleFields = computed(() =>
|
const visibleFields = computed(() =>
|
||||||
isPaymentCard.value
|
isPaymentCard.value
|
||||||
? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field))
|
? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field))
|
||||||
|
|||||||
543
client/src/components/reservation/ReservationV4TraceCard.vue
Normal file
543
client/src/components/reservation/ReservationV4TraceCard.vue
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="trace-card"
|
||||||
|
data-testid="trace-card"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-for="item in traceItems"
|
||||||
|
:key="item.index"
|
||||||
|
class="trace-item"
|
||||||
|
>
|
||||||
|
<header class="trace-item__header">
|
||||||
|
<span>{{ t('taskV4.trace.itemLabel', { index: item.index + 1 }) }}</span>
|
||||||
|
<strong>{{ item.type === 'EXTRA_BED' ? t('taskV4.trace.extraBedTitle') : t('taskV4.trace.generalTitle') }}</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="item.type === 'EXTRA_BED'"
|
||||||
|
class="trace-item__action"
|
||||||
|
>
|
||||||
|
<span>{{ t('taskV4.trace.action') }}</span>
|
||||||
|
<strong>SET EXTRA BED</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="trace-item__fields">
|
||||||
|
<label
|
||||||
|
v-for="field in fieldsForItem(item)"
|
||||||
|
:key="fieldKey(field)"
|
||||||
|
class="trace-field"
|
||||||
|
:class="{ 'trace-field--readonly': !isEditable(field) }"
|
||||||
|
>
|
||||||
|
<span class="trace-field__label">
|
||||||
|
{{ field.display_name }}
|
||||||
|
<sup v-if="field.required">*</sup>
|
||||||
|
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-if="isEditable(field) && isSelectField(field)"
|
||||||
|
class="trace-field__control"
|
||||||
|
:name="fieldKey(field)"
|
||||||
|
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
|
||||||
|
:disabled="selectDisabled(field)"
|
||||||
|
:value="fieldValue(field)"
|
||||||
|
@change="updateField(field, $event)"
|
||||||
|
>
|
||||||
|
<option value="">{{ t('taskV4.field.selectPlaceholder') }}</option>
|
||||||
|
<option
|
||||||
|
v-for="option in fieldOptions(field)"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-else-if="isEditable(field) && isTextField(field)"
|
||||||
|
class="trace-field__control trace-field__control--textarea"
|
||||||
|
:name="fieldKey(field)"
|
||||||
|
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
|
||||||
|
:value="fieldValue(field)"
|
||||||
|
rows="3"
|
||||||
|
@input="updateField(field, $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="isEditable(field) && isPositiveIntegerField(field)"
|
||||||
|
class="trace-field__control"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
inputmode="numeric"
|
||||||
|
:name="fieldKey(field)"
|
||||||
|
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
|
||||||
|
:value="fieldValue(field)"
|
||||||
|
@input="updateField(field, $event)"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="isEditable(field)"
|
||||||
|
class="trace-field__control"
|
||||||
|
type="text"
|
||||||
|
:name="fieldKey(field)"
|
||||||
|
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
|
||||||
|
:value="fieldValue(field)"
|
||||||
|
@input="updateField(field, $event)"
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="trace-field__static"
|
||||||
|
>
|
||||||
|
{{ staticFieldValue(field) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<small
|
||||||
|
v-if="field.control_hint"
|
||||||
|
class="trace-field__hint"
|
||||||
|
>{{ field.control_hint }}</small>
|
||||||
|
<small
|
||||||
|
v-for="hint in lookupHints(field)"
|
||||||
|
:key="hint"
|
||||||
|
class="trace-field__hint"
|
||||||
|
>{{ hint }}</small>
|
||||||
|
<small
|
||||||
|
v-for="error in visibleFieldErrors(field)"
|
||||||
|
:key="error"
|
||||||
|
class="trace-field__error"
|
||||||
|
>{{ error }}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { fetchReservationV4RoomTypeLookups } from '@/services/reservationService'
|
||||||
|
import type {
|
||||||
|
ReservationRecord,
|
||||||
|
ReservationV4CatalogLookupItem,
|
||||||
|
ReservationV4CatalogLookupResult,
|
||||||
|
ReservationV4TaskCardFieldResult,
|
||||||
|
ReservationV4TaskCardResult,
|
||||||
|
} from '@/types/reservation'
|
||||||
|
import {
|
||||||
|
isReservationV4EditableField,
|
||||||
|
isReservationV4TraceSafeField,
|
||||||
|
fieldPathToPointer,
|
||||||
|
readV4FieldValue,
|
||||||
|
reservationV4FieldKey,
|
||||||
|
reservationV4LookupKindForOptionsSource,
|
||||||
|
stringifyReservationV4SafeDisplayValue,
|
||||||
|
} from '@/utils/reservationV4FieldRules'
|
||||||
|
|
||||||
|
type TraceItemType = 'GENERAL' | 'EXTRA_BED'
|
||||||
|
type TraceItem = {
|
||||||
|
index: number
|
||||||
|
type: TraceItemType
|
||||||
|
}
|
||||||
|
type FieldOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
card: ReservationV4TaskCardResult
|
||||||
|
modelValue: ReservationRecord
|
||||||
|
readOnly: boolean
|
||||||
|
editableKeys: string[]
|
||||||
|
validationErrors?: Record<string, string>
|
||||||
|
hotelId?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ReservationRecord]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const roomTypeLookup = reactive<{
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
result: ReservationV4CatalogLookupResult | null
|
||||||
|
}>({
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
result: null,
|
||||||
|
})
|
||||||
|
let roomTypeLookupRequestVersion = 0
|
||||||
|
|
||||||
|
const traceFields = computed(() => props.card.fields.filter(isReservationV4TraceSafeField))
|
||||||
|
const hasRoomTypeLookupField = computed(() =>
|
||||||
|
traceFields.value.some((field) => reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE'),
|
||||||
|
)
|
||||||
|
const traceItems = computed<TraceItem[]>(() => {
|
||||||
|
const itemIndexes = [...new Set(traceFields.value.map(fieldIndex).filter((index) => index >= 0))]
|
||||||
|
return itemIndexes.sort((left, right) => left - right).map((index) => ({
|
||||||
|
index,
|
||||||
|
type: traceItemType(index),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[hasRoomTypeLookupField, () => props.hotelId],
|
||||||
|
() => {
|
||||||
|
if (hasRoomTypeLookupField.value) {
|
||||||
|
void loadRoomTypeLookup()
|
||||||
|
} else {
|
||||||
|
roomTypeLookupRequestVersion += 1
|
||||||
|
roomTypeLookup.loading = false
|
||||||
|
roomTypeLookup.error = ''
|
||||||
|
roomTypeLookup.result = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function fieldsForItem(item: TraceItem): ReservationV4TaskCardFieldResult[] {
|
||||||
|
const preferredOrder = item.type === 'EXTRA_BED'
|
||||||
|
? ['target_room_type_code', 'extra_bed_room_count', 'department_code']
|
||||||
|
: ['text', 'department_code']
|
||||||
|
return preferredOrder
|
||||||
|
.map((suffix) => traceFields.value.find((field) => fieldPointer(field) === `/trace_items/${item.index}/${suffix}`))
|
||||||
|
.filter((field): field is ReservationV4TaskCardFieldResult => Boolean(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
const key = fieldKey(field)
|
||||||
|
return !props.readOnly &&
|
||||||
|
props.editableKeys.includes(key) &&
|
||||||
|
isReservationV4EditableField(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
return fixedFieldOptions(field).length > 0 ||
|
||||||
|
isDepartmentField(field) ||
|
||||||
|
reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
return fieldPointer(field).endsWith('/text')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositiveIntegerField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
return fieldPointer(field).endsWith('/extra_bed_room_count')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDepartmentField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
return fieldPointer(field).endsWith('/department_code')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
|
||||||
|
return reservationV4FieldKey(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
|
||||||
|
const value = readV4FieldValue(field, props.modelValue)
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return stringifyValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticFieldValue(field: ReservationV4TaskCardFieldResult): string {
|
||||||
|
const value = String(fieldValue(field))
|
||||||
|
return fieldOptions(field).find((option) => option.value === value)?.label ?? stringifyValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
|
||||||
|
if (reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE') {
|
||||||
|
return roomTypeOptions(field)
|
||||||
|
}
|
||||||
|
const options = fixedFieldOptions(field)
|
||||||
|
if (!options.length && isDepartmentField(field)) {
|
||||||
|
const currentValue = String(readV4FieldValue(field, props.modelValue) || '')
|
||||||
|
return currentValue
|
||||||
|
? [{
|
||||||
|
value: currentValue,
|
||||||
|
label: currentValue,
|
||||||
|
}]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
|
||||||
|
return (field.fixed_options ?? []).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label || option.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomTypeOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
|
||||||
|
const options = (roomTypeLookup.result?.items ?? []).map((item) => ({
|
||||||
|
value: item.code,
|
||||||
|
label: lookupItemLabel(item),
|
||||||
|
}))
|
||||||
|
const currentValue = String(readV4FieldValue(field, props.modelValue) || '')
|
||||||
|
if (currentValue && !options.some((option) => option.value === currentValue)) {
|
||||||
|
options.push({
|
||||||
|
value: currentValue,
|
||||||
|
label: currentValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
if (isDepartmentField(field)) {
|
||||||
|
return fixedFieldOptions(field).length === 0
|
||||||
|
}
|
||||||
|
return reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE' &&
|
||||||
|
(roomTypeLookup.loading || Boolean(roomTypeLookup.error))
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupHints(field: ReservationV4TaskCardFieldResult): string[] {
|
||||||
|
if (reservationV4LookupKindForOptionsSource(field.options_source) !== 'ROOM_TYPE') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (roomTypeLookup.loading) {
|
||||||
|
return [t('taskV4.lookup.loading')]
|
||||||
|
}
|
||||||
|
if (roomTypeLookup.error) {
|
||||||
|
return [roomTypeLookup.error]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
|
||||||
|
return [
|
||||||
|
...(field.validation_errors ?? []),
|
||||||
|
props.validationErrors?.[fieldKey(field)] ?? '',
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
|
||||||
|
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
[fieldKey(field)]: inputValue(field, target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputValue(field: ReservationV4TaskCardFieldResult, value: string): string | number {
|
||||||
|
if (!isPositiveIntegerField(field)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value.trim() === '') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const numericValue = Number(value)
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoomTypeLookup(): Promise<void> {
|
||||||
|
const requestVersion = ++roomTypeLookupRequestVersion
|
||||||
|
roomTypeLookup.loading = true
|
||||||
|
roomTypeLookup.error = ''
|
||||||
|
try {
|
||||||
|
const result = await fetchReservationV4RoomTypeLookups({
|
||||||
|
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
|
||||||
|
page_num: 1,
|
||||||
|
page_size: 100,
|
||||||
|
})
|
||||||
|
if (requestVersion === roomTypeLookupRequestVersion) {
|
||||||
|
roomTypeLookup.result = {
|
||||||
|
...result,
|
||||||
|
items: result.items ?? [],
|
||||||
|
warnings: result.warnings ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestVersion === roomTypeLookupRequestVersion) {
|
||||||
|
roomTypeLookup.error = t('taskV4.lookup.error')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestVersion === roomTypeLookupRequestVersion) {
|
||||||
|
roomTypeLookup.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function traceItemType(index: number): TraceItemType {
|
||||||
|
const payloadItem = payloadTraceItem(index)
|
||||||
|
if (readString(payloadItem?.item_type).toUpperCase() === 'EXTRA_BED') {
|
||||||
|
return 'EXTRA_BED'
|
||||||
|
}
|
||||||
|
return traceFields.value.some((field) =>
|
||||||
|
fieldPointer(field) === `/trace_items/${index}/target_room_type_code` ||
|
||||||
|
fieldPointer(field) === `/trace_items/${index}/extra_bed_room_count`,
|
||||||
|
)
|
||||||
|
? 'EXTRA_BED'
|
||||||
|
: 'GENERAL'
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadTraceItem(index: number): ReservationRecord | null {
|
||||||
|
const traceItemsValue = props.card.display_payload?.trace_items
|
||||||
|
if (!Array.isArray(traceItemsValue)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const item = traceItemsValue[index]
|
||||||
|
return isRecord(item) ? item : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldIndex(field: ReservationV4TaskCardFieldResult): number {
|
||||||
|
const match = fieldPointer(field).match(/^\/trace_items\/(\d+)\//)
|
||||||
|
return match ? Number(match[1]) : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldPointer(field: ReservationV4TaskCardFieldResult): string {
|
||||||
|
return field.field_pointer || fieldPathToPointer(field.field_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
|
||||||
|
if (item.display_name && item.display_name !== item.code) {
|
||||||
|
return `${item.code} - ${item.display_name}`
|
||||||
|
}
|
||||||
|
return item.code
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyValue(value: unknown): string {
|
||||||
|
return stringifyReservationV4SafeDisplayValue(
|
||||||
|
value,
|
||||||
|
t('taskV4.field.empty'),
|
||||||
|
t('taskV4.field.hidden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is ReservationRecord {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trace-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 0 20px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid var(--th-color-slate-200);
|
||||||
|
border-radius: var(--th-radius-sm);
|
||||||
|
background: var(--th-color-slate-50);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-item__header,
|
||||||
|
.trace-item__action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-item__header span,
|
||||||
|
.trace-item__action span {
|
||||||
|
color: var(--th-color-blue-600);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-item__header strong,
|
||||||
|
.trace-item__action strong {
|
||||||
|
color: var(--th-color-slate-900);
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-item__action {
|
||||||
|
border-radius: var(--th-radius-sm);
|
||||||
|
background: var(--th-color-white);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-item__fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--th-color-slate-700);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__label sup {
|
||||||
|
color: var(--th-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__label em {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--th-color-slate-100);
|
||||||
|
color: var(--th-color-slate-500);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__control,
|
||||||
|
.trace-field__static {
|
||||||
|
min-height: 38px;
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__control--textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__control[aria-invalid='true'] {
|
||||||
|
border-color: var(--th-color-danger);
|
||||||
|
box-shadow: 0 0 0 3px var(--th-color-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__static {
|
||||||
|
display: block;
|
||||||
|
background: var(--th-color-white);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__hint {
|
||||||
|
color: var(--th-color-slate-500);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-field__error {
|
||||||
|
color: var(--th-color-danger);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.trace-item__fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -630,6 +630,13 @@ export default {
|
|||||||
description: 'The current source message contains a Rooming List item that needs manual handling.',
|
description: 'The current source message contains a Rooming List item that needs manual handling.',
|
||||||
confirmHint: 'This first version only confirms that the item has been handled manually. Guest details and attachment content are not displayed or processed here.',
|
confirmHint: 'This first version only confirms that the item has been handled manually. Guest details and attachment content are not displayed or processed here.',
|
||||||
},
|
},
|
||||||
|
trace: {
|
||||||
|
title: 'Trace notes',
|
||||||
|
itemLabel: 'Item {index}',
|
||||||
|
generalTitle: 'General note',
|
||||||
|
extraBedTitle: 'Extra bed note',
|
||||||
|
action: 'Fixed action',
|
||||||
|
},
|
||||||
lookup: {
|
lookup: {
|
||||||
loading: 'Loading catalog',
|
loading: 'Loading catalog',
|
||||||
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',
|
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',
|
||||||
|
|||||||
@@ -630,6 +630,13 @@ export default {
|
|||||||
description: 'ข้อความต้นทางปัจจุบันมีรายการ Rooming List ที่ต้องให้เจ้าหน้าที่จัดการ',
|
description: 'ข้อความต้นทางปัจจุบันมีรายการ Rooming List ที่ต้องให้เจ้าหน้าที่จัดการ',
|
||||||
confirmHint: 'เวอร์ชันแรกนี้ใช้ยืนยันว่ารายการได้รับการจัดการด้วยคนแล้วเท่านั้น ไม่แสดงหรือประมวลผลรายชื่อแขกและเนื้อหาไฟล์แนบในหน้านี้',
|
confirmHint: 'เวอร์ชันแรกนี้ใช้ยืนยันว่ารายการได้รับการจัดการด้วยคนแล้วเท่านั้น ไม่แสดงหรือประมวลผลรายชื่อแขกและเนื้อหาไฟล์แนบในหน้านี้',
|
||||||
},
|
},
|
||||||
|
trace: {
|
||||||
|
title: 'หมายเหตุ Trace',
|
||||||
|
itemLabel: 'รายการ {index}',
|
||||||
|
generalTitle: 'หมายเหตุทั่วไป',
|
||||||
|
extraBedTitle: 'รายการเตียงเสริม',
|
||||||
|
action: 'การดำเนินการคงที่',
|
||||||
|
},
|
||||||
lookup: {
|
lookup: {
|
||||||
loading: 'กำลังโหลดแค็ตตาล็อก',
|
loading: 'กำลังโหลดแค็ตตาล็อก',
|
||||||
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
|
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
|
||||||
|
|||||||
@@ -630,6 +630,13 @@ export default {
|
|||||||
description: '当前来源消息包含需要人工处理的 Rooming List 事项,请在酒店内部流程处理后确认该卡片。',
|
description: '当前来源消息包含需要人工处理的 Rooming List 事项,请在酒店内部流程处理后确认该卡片。',
|
||||||
confirmHint: '第一版仅确认该事项已由人工处理,不展示或处理名单明细和附件内容。',
|
confirmHint: '第一版仅确认该事项已由人工处理,不展示或处理名单明细和附件内容。',
|
||||||
},
|
},
|
||||||
|
trace: {
|
||||||
|
title: 'Trace / 追踪备注事项',
|
||||||
|
itemLabel: '事项 {index}',
|
||||||
|
generalTitle: '普通事项',
|
||||||
|
extraBedTitle: '加床事项',
|
||||||
|
action: '固定动作',
|
||||||
|
},
|
||||||
lookup: {
|
lookup: {
|
||||||
loading: '目录加载中',
|
loading: '目录加载中',
|
||||||
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
|
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ describe('reservation V4 pages', () => {
|
|||||||
|
|
||||||
it('keeps V4 task card layout classes for styled card metadata and payload summary', async () => {
|
it('keeps V4 task card layout classes for styled card metadata and payload summary', async () => {
|
||||||
const detail = createOrderTaskDetail()
|
const detail = createOrderTaskDetail()
|
||||||
detail.business_cards[0]!.card_type = 'TRACE_RESERVATION_NOTES'
|
detail.business_cards[0]!.card_type = 'VOUCHER'
|
||||||
detail.business_cards[0]!.display_payload = {
|
detail.business_cards[0]!.display_payload = {
|
||||||
booking_scenario: 'STANDARD',
|
booking_scenario: 'STANDARD',
|
||||||
relevant_message_excerpt: 'Please keep this visible.',
|
relevant_message_excerpt: 'Please keep this visible.',
|
||||||
@@ -514,6 +514,178 @@ describe('reservation V4 pages', () => {
|
|||||||
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
|
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders GENERAL Trace cards with fixed department options and confirms safe trace fields only', async () => {
|
||||||
|
const detail = createOrderTaskDetail()
|
||||||
|
useTraceBusinessCard(detail, 'GENERAL')
|
||||||
|
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||||
|
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
|
||||||
|
...detail,
|
||||||
|
business_cards: [
|
||||||
|
{
|
||||||
|
...detail.business_cards[0]!,
|
||||||
|
card_status: 'CONFIRMED',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = await mountWithPlugins(
|
||||||
|
ReservationV4OrderTaskDetailView,
|
||||||
|
'/reservation/order-tasks/9001',
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const traceSection = wrapper
|
||||||
|
.findAll('.task-card-section')
|
||||||
|
.find((section) => section.find('[data-testid="trace-card"]').exists())
|
||||||
|
expect(traceSection).toBeTruthy()
|
||||||
|
expect((traceSection!.find('textarea[name="/trace_items/0/text"]').element as HTMLTextAreaElement).value)
|
||||||
|
.toBe('Late arrival note')
|
||||||
|
expect(traceSection!.text()).not.toContain('CONTENT_SHOULD_NOT_RENDER')
|
||||||
|
expect(traceSection!.text()).not.toContain('TARGET_ORDER_SHOULD_NOT_RENDER')
|
||||||
|
expect(traceSection!.text()).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER')
|
||||||
|
expect(traceSection!.text()).not.toContain('https://oss.example/private/trace.pdf')
|
||||||
|
expect(traceSection!.find('details.safe-payload').exists()).toBe(false)
|
||||||
|
|
||||||
|
const departmentOptions = traceSection!
|
||||||
|
.find('select[name="/trace_items/0/department_code"]')
|
||||||
|
.findAll('option')
|
||||||
|
.map((option) => option.attributes('value'))
|
||||||
|
expect(departmentOptions).toEqual(['', 'FO', 'HSK', 'FO+HSK'])
|
||||||
|
|
||||||
|
await traceSection!.find('textarea[name="/trace_items/0/text"]').setValue('Arrange late arrival follow-up')
|
||||||
|
await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('HSK')
|
||||||
|
await traceSection!.find('button.primary-button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-trace', {
|
||||||
|
version: 10,
|
||||||
|
confirmed_payload: {
|
||||||
|
trace_items: [
|
||||||
|
{
|
||||||
|
text: 'Arrange late arrival follow-up',
|
||||||
|
department_code: 'HSK',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2]
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('content')
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('target_order')
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER')
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders EXTRA_BED Trace cards with room type lookup, positive count input and confirms safe fields only', async () => {
|
||||||
|
const detail = createOrderTaskDetail()
|
||||||
|
useTraceBusinessCard(detail, 'EXTRA_BED')
|
||||||
|
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||||
|
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue(detail)
|
||||||
|
|
||||||
|
const wrapper = await mountWithPlugins(
|
||||||
|
ReservationV4OrderTaskDetailView,
|
||||||
|
'/reservation/order-tasks/9001',
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const traceSection = wrapper
|
||||||
|
.findAll('.task-card-section')
|
||||||
|
.find((section) => section.find('[data-testid="trace-card"]').exists())
|
||||||
|
expect(traceSection).toBeTruthy()
|
||||||
|
expect(traceSection!.text()).toContain('SET EXTRA BED')
|
||||||
|
expect(traceSection!.find('select[name="/trace_items/0/target_room_type_code"]').text()).toContain('TWN - Twin')
|
||||||
|
const roomCountInput = traceSection!.find('input[name="/trace_items/0/extra_bed_room_count"]')
|
||||||
|
expect(roomCountInput.attributes('type')).toBe('number')
|
||||||
|
expect(roomCountInput.attributes('min')).toBe('1')
|
||||||
|
expect(roomCountInput.attributes('step')).toBe('1')
|
||||||
|
|
||||||
|
await traceSection!.find('select[name="/trace_items/0/target_room_type_code"]').setValue('TWN')
|
||||||
|
await roomCountInput.setValue('0')
|
||||||
|
await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('FO+HSK')
|
||||||
|
await traceSection!.find('button.primary-button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled()
|
||||||
|
expect(traceSection!.text()).toContain('Extra Bed Room Count must be a positive integer')
|
||||||
|
|
||||||
|
await roomCountInput.setValue('2')
|
||||||
|
await traceSection!.find('button.primary-button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-trace', {
|
||||||
|
version: 10,
|
||||||
|
confirmed_payload: {
|
||||||
|
trace_items: [
|
||||||
|
{
|
||||||
|
target_room_type_code: 'TWN',
|
||||||
|
extra_bed_room_count: 2,
|
||||||
|
department_code: 'FO+HSK',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2]
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('content')
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('target_order')
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves REVIEW_REQUIRED Trace cards with field errors and safe review overrides', async () => {
|
||||||
|
const detail = createOrderTaskDetail()
|
||||||
|
useTraceBusinessCard(detail, 'GENERAL', 'REVIEW_REQUIRED')
|
||||||
|
detail.order_task.order_id = 'order-2001'
|
||||||
|
detail.order_task.target_resolution_status = 'RESOLVED'
|
||||||
|
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||||
|
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockResolvedValue({
|
||||||
|
...detail,
|
||||||
|
business_cards: [
|
||||||
|
{
|
||||||
|
...detail.business_cards[0]!,
|
||||||
|
card_status: 'PENDING_CONFIRM',
|
||||||
|
review_status: 'RESOLVED',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = await mountWithPlugins(
|
||||||
|
ReservationV4OrderTaskDetailView,
|
||||||
|
'/reservation/order-tasks/9001',
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const traceSection = wrapper
|
||||||
|
.findAll('.task-card-section')
|
||||||
|
.find((section) => section.find('[data-testid="trace-card"]').exists())
|
||||||
|
expect(traceSection).toBeTruthy()
|
||||||
|
expect(traceSection!.find('.trace-field__error').text()).toContain('Department is required')
|
||||||
|
|
||||||
|
await traceSection!.find('textarea[name="/trace_items/0/text"]').setValue('Confirm trace review text')
|
||||||
|
await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('FO')
|
||||||
|
await traceSection!.find('textarea[name="v4_review_reason"]').setValue('confirmed trace note')
|
||||||
|
await traceSection!.find('button.primary-button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled()
|
||||||
|
expect(service.resolveReservationV4OrderTaskCardReview).toHaveBeenCalledWith('9001', 'card-trace', {
|
||||||
|
version: 10,
|
||||||
|
confirmed_order_id: 'order-2001',
|
||||||
|
reason: 'confirmed trace note',
|
||||||
|
field_overrides: [
|
||||||
|
{
|
||||||
|
field_pointer: '/trace_items/0/text',
|
||||||
|
value: 'Confirm trace review text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field_pointer: '/trace_items/0/department_code',
|
||||||
|
value: 'FO',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const submittedRequest = vi.mocked(service.resolveReservationV4OrderTaskCardReview).mock.calls[0]?.[2]
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('content')
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('target_order')
|
||||||
|
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
|
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
|
||||||
const detail = createOrderTaskDetail({
|
const detail = createOrderTaskDetail({
|
||||||
businessEventType: 'NEW_BOOKING',
|
businessEventType: 'NEW_BOOKING',
|
||||||
@@ -1557,6 +1729,124 @@ function useBoundOrder(detail: ReservationV4OrderTaskDetailResult): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useTraceBusinessCard(
|
||||||
|
detail: ReservationV4OrderTaskDetailResult,
|
||||||
|
itemType: 'GENERAL' | 'EXTRA_BED',
|
||||||
|
cardStatus = 'PENDING_CONFIRM',
|
||||||
|
): void {
|
||||||
|
const isReviewRequired = cardStatus === 'REVIEW_REQUIRED'
|
||||||
|
detail.business_cards = [
|
||||||
|
createCard('card-trace', 'TRACE_RESERVATION_NOTES', cardStatus, {
|
||||||
|
event_type: 'TRACE_RESERVATION_NOTES',
|
||||||
|
version: 10,
|
||||||
|
availability: createAvailability({
|
||||||
|
confirmable: cardStatus === 'PENDING_CONFIRM',
|
||||||
|
reviewable: isReviewRequired,
|
||||||
|
editable: true,
|
||||||
|
read_only: false,
|
||||||
|
}),
|
||||||
|
display_payload: {
|
||||||
|
trace_items: [
|
||||||
|
itemType === 'EXTRA_BED'
|
||||||
|
? {
|
||||||
|
item_type: 'EXTRA_BED',
|
||||||
|
target_room_type_code: 'TWN',
|
||||||
|
extra_bed_room_count: 1,
|
||||||
|
department_code: 'FO',
|
||||||
|
content: 'CONTENT_SHOULD_NOT_RENDER',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
item_type: 'GENERAL',
|
||||||
|
text: 'Late arrival note',
|
||||||
|
department_code: 'FO',
|
||||||
|
content: 'CONTENT_SHOULD_NOT_RENDER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
target_order: 'TARGET_ORDER_SHOULD_NOT_RENDER',
|
||||||
|
ai_payload_json: 'AI_PAYLOAD_SHOULD_NOT_RENDER',
|
||||||
|
attachment_url: 'https://oss.example/private/trace.pdf',
|
||||||
|
},
|
||||||
|
fields: itemType === 'EXTRA_BED'
|
||||||
|
? [
|
||||||
|
createField('/trace_items/0/target_room_type_code', {
|
||||||
|
display_name: 'Target Room Type',
|
||||||
|
value: 'TWN',
|
||||||
|
options_source: 'RESERVATION_V4_ROOM_TYPE_CATALOG',
|
||||||
|
control_type: 'SELECT',
|
||||||
|
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
|
||||||
|
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
|
||||||
|
}),
|
||||||
|
createField('/trace_items/0/extra_bed_room_count', {
|
||||||
|
display_name: 'Extra Bed Room Count',
|
||||||
|
value: 1,
|
||||||
|
control_type: 'NUMBER',
|
||||||
|
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
|
||||||
|
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
|
||||||
|
}),
|
||||||
|
createTraceDepartmentField(isReviewRequired),
|
||||||
|
createField('/trace_items/0/content', {
|
||||||
|
display_name: 'Legacy content',
|
||||||
|
value: 'CONTENT_SHOULD_NOT_RENDER',
|
||||||
|
editable: true,
|
||||||
|
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
|
||||||
|
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
createField('/trace_items/0/text', {
|
||||||
|
display_name: 'Trace Text',
|
||||||
|
value: 'Late arrival note',
|
||||||
|
control_type: 'TEXTAREA',
|
||||||
|
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
|
||||||
|
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
|
||||||
|
}),
|
||||||
|
createTraceDepartmentField(isReviewRequired, isReviewRequired ? ['Department is required'] : []),
|
||||||
|
createField('/trace_items/0/content', {
|
||||||
|
display_name: 'Legacy content',
|
||||||
|
value: 'CONTENT_SHOULD_NOT_RENDER',
|
||||||
|
editable: true,
|
||||||
|
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
|
||||||
|
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
detail.card_counts = {
|
||||||
|
...detail.card_counts,
|
||||||
|
pending_confirm_count: cardStatus === 'PENDING_CONFIRM' ? 2 : 1,
|
||||||
|
review_required_count: isReviewRequired ? 1 : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTraceDepartmentField(
|
||||||
|
isReviewRequired: boolean,
|
||||||
|
validationErrors: string[] = [],
|
||||||
|
): ReservationV4TaskCardResult['fields'][number] {
|
||||||
|
return createField('/trace_items/0/department_code', {
|
||||||
|
display_name: 'Department',
|
||||||
|
value: 'FO',
|
||||||
|
control_type: 'SELECT',
|
||||||
|
options_source: 'reservation_v4_trace_department_fixed',
|
||||||
|
fixed_options: [
|
||||||
|
{
|
||||||
|
value: 'FO',
|
||||||
|
label: 'FO',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'HSK',
|
||||||
|
label: 'HSK',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FO+HSK',
|
||||||
|
label: 'FO+HSK',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
|
||||||
|
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
|
||||||
|
validation_errors: validationErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createPaymentConversationMedia(): SourceMessageOriginalMedia[] {
|
function createPaymentConversationMedia(): SourceMessageOriginalMedia[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1830,6 +2120,7 @@ function createField(
|
|||||||
edit_scope: overrides.edit_scope ?? 'NORMAL_TASK',
|
edit_scope: overrides.edit_scope ?? 'NORMAL_TASK',
|
||||||
write_target: overrides.write_target ?? 'CONFIRMED_PAYLOAD_JSON',
|
write_target: overrides.write_target ?? 'CONFIRMED_PAYLOAD_JSON',
|
||||||
options_source: overrides.options_source ?? null,
|
options_source: overrides.options_source ?? null,
|
||||||
|
fixed_options: overrides.fixed_options ?? null,
|
||||||
raw_readonly: overrides.raw_readonly ?? false,
|
raw_readonly: overrides.raw_readonly ?? false,
|
||||||
validation_errors: overrides.validation_errors ?? [],
|
validation_errors: overrides.validation_errors ?? [],
|
||||||
control_hint: overrides.control_hint ?? null,
|
control_hint: overrides.control_hint ?? null,
|
||||||
|
|||||||
@@ -358,11 +358,17 @@ export interface ReservationV4TaskCardFieldResult {
|
|||||||
edit_scope: string | null
|
edit_scope: string | null
|
||||||
write_target: string | null
|
write_target: string | null
|
||||||
options_source: string | null
|
options_source: string | null
|
||||||
|
fixed_options?: ReservationV4TaskCardFieldOptionResult[] | null
|
||||||
raw_readonly: boolean | null
|
raw_readonly: boolean | null
|
||||||
validation_errors: string[]
|
validation_errors: string[]
|
||||||
control_hint: string | null
|
control_hint: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReservationV4TaskCardFieldOptionResult {
|
||||||
|
value: string
|
||||||
|
label: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReservationV4RoomInformationRoomItem extends ReservationRecord {
|
export interface ReservationV4RoomInformationRoomItem extends ReservationRecord {
|
||||||
room_type_code?: string | null
|
room_type_code?: string | null
|
||||||
room_count?: number | string | null
|
room_count?: number | string | null
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ const reviewWriteTargets = new Set([
|
|||||||
'FIELD_OVERRIDES',
|
'FIELD_OVERRIDES',
|
||||||
])
|
])
|
||||||
const readonlyControlTypes = new Set(['READONLY', 'FILE', 'WORKFLOW_STATE', 'STRUCTURED_TABLE'])
|
const readonlyControlTypes = new Set(['READONLY', 'FILE', 'WORKFLOW_STATE', 'STRUCTURED_TABLE'])
|
||||||
const reviewWritablePointerPrefixes = ['/basic_information/', '/room_information/final_values/', '/business_fields/']
|
const reviewWritablePointerPrefixes = [
|
||||||
|
'/basic_information/',
|
||||||
|
'/room_information/final_values/',
|
||||||
|
'/business_fields/',
|
||||||
|
'/trace_items/',
|
||||||
|
]
|
||||||
const roomInformationFieldPrefix = '/room_information/final_values/'
|
const roomInformationFieldPrefix = '/room_information/final_values/'
|
||||||
|
const traceFieldPattern = /^\/trace_items\/\d+\/(?:text|department_code|target_room_type_code|extra_bed_room_count)$/
|
||||||
const hiddenDisplayValue = Symbol('reservation-v4-hidden-display-value')
|
const hiddenDisplayValue = Symbol('reservation-v4-hidden-display-value')
|
||||||
|
|
||||||
export type ReservationV4FieldErrorMap = Record<string, string>
|
export type ReservationV4FieldErrorMap = Record<string, string>
|
||||||
@@ -43,6 +49,11 @@ export function isReservationV4RoomInformationSafeField(field: ReservationV4Task
|
|||||||
return pointer.startsWith(roomInformationFieldPrefix) && !isForbiddenRoomInformationPointer(pointer)
|
return pointer.startsWith(roomInformationFieldPrefix) && !isForbiddenRoomInformationPointer(pointer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isReservationV4TraceSafeField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
const pointer = field.field_pointer || fieldPathToPointer(field.field_path)
|
||||||
|
return traceFieldPattern.test(pointer)
|
||||||
|
}
|
||||||
|
|
||||||
export function buildReservationV4ConfirmedPayload(
|
export function buildReservationV4ConfirmedPayload(
|
||||||
fields: ReservationV4TaskCardFieldResult[],
|
fields: ReservationV4TaskCardFieldResult[],
|
||||||
values: ReservationRecord,
|
values: ReservationRecord,
|
||||||
@@ -73,8 +84,13 @@ export function validateReservationV4Fields(
|
|||||||
): ReservationV4FieldErrorMap {
|
): ReservationV4FieldErrorMap {
|
||||||
return fields.filter(isReservationV4EditableField).reduce<ReservationV4FieldErrorMap>((errors, field) => {
|
return fields.filter(isReservationV4EditableField).reduce<ReservationV4FieldErrorMap>((errors, field) => {
|
||||||
const key = reservationV4FieldKey(field)
|
const key = reservationV4FieldKey(field)
|
||||||
if (field.required && isEmptyV4Value(readV4FieldValue(field, values))) {
|
const value = readV4FieldValue(field, values)
|
||||||
|
if (field.required && isEmptyV4Value(value)) {
|
||||||
errors[key] = `${field.display_name} is required`
|
errors[key] = `${field.display_name} is required`
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
if (isTracePositiveIntegerField(field) && !isEmptyV4Value(value) && !isPositiveIntegerValue(value)) {
|
||||||
|
errors[key] = `${field.display_name} must be a positive integer`
|
||||||
}
|
}
|
||||||
return errors
|
return errors
|
||||||
}, {})
|
}, {})
|
||||||
@@ -383,6 +399,21 @@ function isForbiddenRoomInformationPointer(pointer: string): boolean {
|
|||||||
normalizedPointer.includes('/locator_value')
|
normalizedPointer.includes('/locator_value')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTracePositiveIntegerField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
|
const pointer = field.field_pointer || fieldPathToPointer(field.field_path)
|
||||||
|
return /^\/trace_items\/\d+\/extra_bed_room_count$/.test(pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositiveIntegerValue(value: unknown): boolean {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) && value > 0
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return /^[1-9]\d*$/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
function isUnsafeUrlLikeValue(value: string): boolean {
|
function isUnsafeUrlLikeValue(value: string): boolean {
|
||||||
const trimmedValue = value.trim()
|
const trimmedValue = value.trim()
|
||||||
return /^(https?:\/\/|oss:\/\/|s3:\/\/)/i.test(trimmedValue)
|
return /^(https?:\/\/|oss:\/\/|s3:\/\/)/i.test(trimmedValue)
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ import {
|
|||||||
isReservationV4ConfirmWritableField,
|
isReservationV4ConfirmWritableField,
|
||||||
isReservationV4RoomInformationSafeField,
|
isReservationV4RoomInformationSafeField,
|
||||||
isReservationV4ReviewWritableField,
|
isReservationV4ReviewWritableField,
|
||||||
|
isReservationV4TraceSafeField,
|
||||||
mapReservationV4BackendDetailsToFields,
|
mapReservationV4BackendDetailsToFields,
|
||||||
reservationV4FieldKey,
|
reservationV4FieldKey,
|
||||||
validateReservationV4Fields,
|
validateReservationV4Fields,
|
||||||
@@ -468,11 +469,15 @@ function submissionFieldsForCard(card: ReservationV4TaskCardResult): Reservation
|
|||||||
if (isVersionOnlyConfirmCard(card)) {
|
if (isVersionOnlyConfirmCard(card)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return card.card_type === 'ROOM_INFORMATION'
|
if (card.card_type === 'ROOM_INFORMATION') {
|
||||||
? card.fields.filter((field) =>
|
return card.fields.filter((field) =>
|
||||||
isReservationV4RoomInformationSafeField(field) && !isGroupRoomInformationBreakfastField(card, field),
|
isReservationV4RoomInformationSafeField(field) && !isGroupRoomInformationBreakfastField(card, field),
|
||||||
)
|
)
|
||||||
: card.fields
|
}
|
||||||
|
if (isTraceCard(card)) {
|
||||||
|
return card.fields.filter(isReservationV4TraceSafeField)
|
||||||
|
}
|
||||||
|
return card.fields
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConfirmRequest(
|
function buildConfirmRequest(
|
||||||
@@ -498,6 +503,10 @@ function isRoomingListCard(card: ReservationV4TaskCardResult): boolean {
|
|||||||
return card.card_type === 'ROOMING_LIST'
|
return card.card_type === 'ROOMING_LIST'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTraceCard(card: ReservationV4TaskCardResult): boolean {
|
||||||
|
return card.card_type === 'TRACE_RESERVATION_NOTES'
|
||||||
|
}
|
||||||
|
|
||||||
function isVersionOnlyConfirmCard(card: ReservationV4TaskCardResult): boolean {
|
function isVersionOnlyConfirmCard(card: ReservationV4TaskCardResult): boolean {
|
||||||
return isPaymentCard(card) || isRoomingListCard(card)
|
return isPaymentCard(card) || isRoomingListCard(card)
|
||||||
}
|
}
|
||||||
@@ -538,6 +547,9 @@ function cardTitle(card: ReservationV4TaskCardResult): string {
|
|||||||
if (isRoomingListCard(card)) {
|
if (isRoomingListCard(card)) {
|
||||||
return t('taskV4.roomingList.title')
|
return t('taskV4.roomingList.title')
|
||||||
}
|
}
|
||||||
|
if (isTraceCard(card)) {
|
||||||
|
return t('taskV4.trace.title')
|
||||||
|
}
|
||||||
return formatReservationTaskCard(t, card.card_type)
|
return formatReservationTaskCard(t, card.card_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user