实现V4 Trace事项卡前端展示

This commit is contained in:
andy committed 2026-07-21 18:15:19 +07:00
1 parent 38661257d1
commit f3b6adda17
10 files changed
+931 -7

No files matched your search

@@ -439,6 +439,13 @@ function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
}
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)
if (optionsSource !== 'RESERVATION_V4_GROUP_BOOKING_STATUS_FIXED') {
return []
@@ -58,6 +58,16 @@
:card="card"
: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
v-else
class="task-card-section__fields"
@@ -72,7 +82,7 @@
</template>
<details
v-if="!isRoomInformationCard && !isRoomingListCard && safePayloadRows.length"
v-if="!isRoomInformationCard && !isRoomingListCard && !isTraceCard && safePayloadRows.length"
class="safe-payload"
>
<summary>
@@ -124,6 +134,7 @@
<label>
<span>{{ t('taskV4.reviewReason') }}</span>
<textarea
name="v4_review_reason"
:value="reviewForm.reason"
:placeholder="t('taskV4.reviewReasonPlaceholder')"
rows="3"
@@ -182,6 +193,7 @@ import ReservationV4PaymentAttachmentPreview from '@/components/reservation/Rese
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
import ReservationV4RoomingListCard from '@/components/reservation/ReservationV4RoomingListCard.vue'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import ReservationV4TraceCard from '@/components/reservation/ReservationV4TraceCard.vue'
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
import {
@@ -242,6 +254,7 @@ const { t } = useI18n()
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT')
const isRoomingListCard = computed(() => props.card.card_type === 'ROOMING_LIST')
const isTraceCard = computed(() => props.card.card_type === 'TRACE_RESERVATION_NOTES')
const visibleFields = computed(() =>
isPaymentCard.value
? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field))
@@ -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>