前端调整V4预订事项用户化展示
This commit is contained in:
1 parent
350bb3310b
commit
c521eb6dc0
20 files changed
+703
-657
No files matched your search
@@ -5,17 +5,7 @@
|
||||
<h2 class="th-section-title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="sourceMessageId"
|
||||
class="th-muted"
|
||||
>
|
||||
{{ t('task.sourceMessageId') }}: {{ sourceMessageId }}
|
||||
</p>
|
||||
</div>
|
||||
<ReservationStatusBadge
|
||||
v-if="sourceCard"
|
||||
:status="sourceCard.card_status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="source-grid">
|
||||
@@ -45,11 +35,16 @@
|
||||
<div class="source-preview source-body">
|
||||
<h3>{{ t('taskV4.sourceMessage.body') }}</h3>
|
||||
<p
|
||||
v-if="bodyLoading"
|
||||
v-if="!bodyExpanded"
|
||||
class="source-body__muted"
|
||||
>
|
||||
{{ t('taskV4.sourceMessage.bodyLoading') }}
|
||||
{{ t('taskV4.sourceMessage.bodyCollapsed') }}
|
||||
</p>
|
||||
<template v-else-if="bodyLoading">
|
||||
<p class="source-body__muted">
|
||||
{{ t('taskV4.sourceMessage.bodyLoading') }}
|
||||
</p>
|
||||
</template>
|
||||
<p
|
||||
v-else-if="bodyError"
|
||||
class="source-body__muted"
|
||||
@@ -59,7 +54,7 @@
|
||||
<template v-else-if="messageBodyText">
|
||||
<!-- eslint-disable vue/no-v-html -- only backend-sanitized html_body_sanitized is rendered; raw html_body is never used here. -->
|
||||
<div
|
||||
v-if="showExpandedHtmlBody"
|
||||
v-if="sanitizedHtmlBody"
|
||||
class="source-body__html"
|
||||
v-html="sanitizedHtmlBody"
|
||||
/>
|
||||
@@ -68,16 +63,8 @@
|
||||
v-else
|
||||
class="source-body__text"
|
||||
>
|
||||
{{ visibleMessageBodyText }}
|
||||
{{ messageBodyText }}
|
||||
</p>
|
||||
<button
|
||||
v-if="canToggleBody"
|
||||
type="button"
|
||||
class="source-body__toggle"
|
||||
@click="bodyExpanded = !bodyExpanded"
|
||||
>
|
||||
{{ bodyExpanded ? t('taskV4.sourceMessage.collapseBody') : t('taskV4.sourceMessage.showFullBody') }}
|
||||
</button>
|
||||
</template>
|
||||
<p
|
||||
v-else
|
||||
@@ -85,6 +72,13 @@
|
||||
>
|
||||
{{ t('taskV4.sourceMessage.noBody') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="source-body__toggle"
|
||||
@click="toggleBody"
|
||||
>
|
||||
{{ bodyExpanded ? t('taskV4.sourceMessage.collapseBody') : t('taskV4.sourceMessage.showFullBody') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="source-preview">
|
||||
@@ -125,7 +119,6 @@ import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import { fetchSourceMessageConversation } from '@/services/reservationService'
|
||||
import type {
|
||||
ReservationRecord,
|
||||
@@ -145,7 +138,6 @@ const props = withDefaults(defineProps<{
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const bodyPreviewLength = 360
|
||||
const currentMessage = ref<SourceMessageConversationItem | null>(null)
|
||||
const bodyLoading = ref(false)
|
||||
const bodyError = ref(false)
|
||||
@@ -210,19 +202,8 @@ const messageBodyText = computed(() => {
|
||||
}
|
||||
return compactBodyText(currentMessage.value?.text_body ?? '')
|
||||
})
|
||||
const canToggleBody = computed(() => messageBodyText.value.length > bodyPreviewLength)
|
||||
const visibleMessageBodyText = computed(() => {
|
||||
if (!canToggleBody.value || bodyExpanded.value) {
|
||||
return messageBodyText.value
|
||||
}
|
||||
return `${messageBodyText.value.slice(0, bodyPreviewLength).trimEnd()}...`
|
||||
})
|
||||
const showExpandedHtmlBody = computed(() =>
|
||||
bodyExpanded.value && Boolean(sanitizedHtmlBody.value),
|
||||
)
|
||||
|
||||
watch(sourceMessageId, (nextSourceMessageId) => {
|
||||
void loadCurrentSourceMessage(nextSourceMessageId)
|
||||
watch(sourceMessageId, () => {
|
||||
resetBodyState()
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
|
||||
@@ -230,7 +211,6 @@ async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
|
||||
const requestSequence = bodyRequestSequence
|
||||
currentMessage.value = null
|
||||
bodyError.value = false
|
||||
bodyExpanded.value = false
|
||||
|
||||
if (!nextSourceMessageId) {
|
||||
bodyLoading.value = false
|
||||
@@ -257,6 +237,21 @@ async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBody(): void {
|
||||
bodyExpanded.value = !bodyExpanded.value
|
||||
if (bodyExpanded.value && !currentMessage.value && !bodyLoading.value) {
|
||||
void loadCurrentSourceMessage(sourceMessageId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function resetBodyState(): void {
|
||||
bodyRequestSequence += 1
|
||||
currentMessage.value = null
|
||||
bodyError.value = false
|
||||
bodyExpanded.value = false
|
||||
bodyLoading.value = false
|
||||
}
|
||||
|
||||
interface AttachmentSummary {
|
||||
key: string
|
||||
name: string
|
||||
|
||||
@@ -390,16 +390,6 @@ function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
|
||||
tone: 'warning',
|
||||
})
|
||||
}
|
||||
if (result.catalog_source || result.catalog_version) {
|
||||
hints.push({
|
||||
key: `${kind}-meta`,
|
||||
message: t('taskV4.lookup.catalogMeta', {
|
||||
source: result.catalog_source ?? '-',
|
||||
version: result.catalog_version ?? '-',
|
||||
}),
|
||||
tone: 'normal',
|
||||
})
|
||||
}
|
||||
const invalidValue = invalidLookupValues[reservationV4FieldKey(field)]
|
||||
if (invalidValue) {
|
||||
hints.push({
|
||||
|
||||
@@ -8,30 +8,31 @@
|
||||
<h2 class="th-section-title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p class="task-card-section__id">
|
||||
#{{ card.card_id }}
|
||||
</p>
|
||||
</div>
|
||||
<ReservationStatusBadge :status="card.card_status" />
|
||||
<div class="task-card-section__status-actions">
|
||||
<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 class="card-meta-grid">
|
||||
<div class="card-meta-item">
|
||||
<span>{{ t('task.reviewStatus') }}</span>
|
||||
<strong>{{ card.review_status ?? '-' }}</strong>
|
||||
</div>
|
||||
<div class="card-meta-item">
|
||||
<span>{{ t('taskV4.readonlyReason') }}</span>
|
||||
<strong>{{ availabilityReason || '-' }}</strong>
|
||||
</div>
|
||||
<div class="card-meta-item">
|
||||
<span>{{ t('taskV4.blockedReason') }}</span>
|
||||
<strong>{{ card.availability.blocked_reason ?? '-' }}</strong>
|
||||
</div>
|
||||
<div class="card-meta-item">
|
||||
<span>{{ t('taskV4.orderTaskId') }}</span>
|
||||
<strong>{{ orderTaskId ?? '-' }}</strong>
|
||||
</div>
|
||||
<div
|
||||
v-if="cardNotices.length"
|
||||
class="card-notice-list"
|
||||
>
|
||||
<p
|
||||
v-for="notice in cardNotices"
|
||||
:key="notice"
|
||||
>
|
||||
{{ notice }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ReservationV4RoomInformationCard
|
||||
@@ -81,24 +82,6 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<details
|
||||
v-if="!isRoomInformationCard && !isRoomingListCard && !isTraceCard && safePayloadRows.length"
|
||||
class="safe-payload"
|
||||
>
|
||||
<summary>
|
||||
<span>{{ t('taskV4.displayPayload') }}</span>
|
||||
</summary>
|
||||
<dl>
|
||||
<template
|
||||
v-for="row in safePayloadRows"
|
||||
:key="row.key"
|
||||
>
|
||||
<dt>{{ row.key }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<div
|
||||
v-if="actionErrors.length"
|
||||
class="action-message action-message--error"
|
||||
@@ -141,39 +124,13 @@
|
||||
@input="updateReviewForm('reason', $event)"
|
||||
/>
|
||||
</label>
|
||||
<div class="review-box__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="primary-button"
|
||||
:disabled="!canReview"
|
||||
@click="emit('resolveReview')"
|
||||
>
|
||||
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
|
||||
</button>
|
||||
<RouterLink
|
||||
v-if="boundOrderId && !isRoomingListCard"
|
||||
class="card-order-link"
|
||||
:to="`/reservation/orders/${boundOrderId}`"
|
||||
>
|
||||
{{ t('task.viewOrder') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="card-actions"
|
||||
v-if="showOrderLink"
|
||||
class="card-secondary-actions"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-button"
|
||||
:disabled="!canConfirm"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
|
||||
</button>
|
||||
<RouterLink
|
||||
v-if="boundOrderId && !isRoomingListCard"
|
||||
class="card-order-link"
|
||||
:to="`/reservation/orders/${boundOrderId}`"
|
||||
>
|
||||
@@ -196,10 +153,6 @@ import ReservationV4TaskCardFieldRenderer from '@/components/reservation/Reserva
|
||||
import ReservationV4TraceCard from '@/components/reservation/ReservationV4TraceCard.vue'
|
||||
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
|
||||
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
|
||||
import {
|
||||
isReservationV4UnsafeDisplayKey,
|
||||
stringifyReservationV4SafeDisplayValue,
|
||||
} from '@/utils/reservationV4FieldRules'
|
||||
|
||||
type ReviewForm = {
|
||||
confirmed_order_id: string
|
||||
@@ -214,7 +167,6 @@ const props = withDefaults(defineProps<{
|
||||
editableKeys: string[]
|
||||
validationErrors?: Record<string, string>
|
||||
hotelId?: string
|
||||
orderTaskId?: string | null
|
||||
sourceMessageId?: string | null
|
||||
orderReference?: string | null
|
||||
targetLocator?: string | null
|
||||
@@ -229,7 +181,6 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
validationErrors: () => ({}),
|
||||
hotelId: undefined,
|
||||
orderTaskId: null,
|
||||
sourceMessageId: null,
|
||||
orderReference: null,
|
||||
targetLocator: null,
|
||||
@@ -267,22 +218,17 @@ const availabilityReason = computed(() => {
|
||||
? formatReservationReadonlyReason(t, props.card.availability.readonly_reason_code)
|
||||
: '')
|
||||
})
|
||||
|
||||
const safePayloadRows = computed(() => {
|
||||
if (!props.card.display_payload) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(props.card.display_payload)
|
||||
.filter(([key]) => !isReservationV4UnsafeDisplayKey(key))
|
||||
.slice(0, 12)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: stringifyReservationV4SafeDisplayValue(
|
||||
value,
|
||||
t('taskV4.field.empty'),
|
||||
t('taskV4.field.hidden'),
|
||||
),
|
||||
}))
|
||||
const showPrimaryAction = computed(() =>
|
||||
props.card.card_status === 'PENDING_CONFIRM' || props.card.card_status === 'REVIEW_REQUIRED',
|
||||
)
|
||||
const primaryActionDisabled = computed(() =>
|
||||
props.card.card_status === 'REVIEW_REQUIRED' ? !props.canReview : !props.canConfirm,
|
||||
)
|
||||
const showOrderLink = computed(() => Boolean(props.boundOrderId && !isRoomingListCard.value))
|
||||
const cardNotices = computed(() => {
|
||||
return [
|
||||
availabilityReason.value,
|
||||
].filter((notice): notice is string => Boolean(notice?.trim()))
|
||||
})
|
||||
|
||||
function updateReviewForm(field: keyof ReviewForm, event: Event): void {
|
||||
@@ -297,6 +243,14 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
const normalizedKey = `${field.field_pointer || ''} ${field.field_path || ''}`.toLowerCase()
|
||||
return normalizedKey.includes('attachment_ids')
|
||||
}
|
||||
|
||||
function handlePrimaryAction(): void {
|
||||
if (props.card.card_status === 'REVIEW_REQUIRED') {
|
||||
emit('resolveReview')
|
||||
return
|
||||
}
|
||||
emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -306,7 +260,7 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
|
||||
.task-card-section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--th-color-slate-200);
|
||||
@@ -319,42 +273,28 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-card-section__id {
|
||||
margin: 0;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
.task-card-section__status-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px 20px 18px;
|
||||
}
|
||||
|
||||
.card-meta-item {
|
||||
.card-notice-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
border-bottom: 1px solid var(--th-color-slate-100);
|
||||
background: var(--th-color-slate-50);
|
||||
padding: 10px 12px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.card-meta-item span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-meta-item strong {
|
||||
color: var(--th-color-slate-900);
|
||||
.card-notice-list p {
|
||||
margin: 0;
|
||||
color: var(--th-color-slate-600);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-card-section__fields {
|
||||
@@ -387,7 +327,7 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
.card-secondary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
@@ -396,7 +336,7 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
|
||||
.review-box {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
border-top: 1px solid var(--th-color-slate-200);
|
||||
@@ -433,12 +373,6 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.review-box__actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.action-message {
|
||||
margin: 0 20px 18px;
|
||||
border-radius: var(--th-radius-sm);
|
||||
@@ -465,75 +399,6 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
color: var(--th-color-success);
|
||||
}
|
||||
|
||||
.safe-payload {
|
||||
margin: 0 20px 18px;
|
||||
}
|
||||
|
||||
.safe-payload summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-700);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
list-style: none;
|
||||
padding: 9px 12px;
|
||||
transition: background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.safe-payload summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.safe-payload summary::after {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 2px solid var(--th-color-slate-500);
|
||||
border-bottom: 2px solid var(--th-color-slate-500);
|
||||
transform: rotate(45deg) translateY(-2px);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.safe-payload[open] summary {
|
||||
border-color: var(--th-color-blue-600);
|
||||
background: var(--th-color-info-bg);
|
||||
}
|
||||
|
||||
.safe-payload[open] summary::after {
|
||||
transform: rotate(225deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.safe-payload dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 220px) minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 var(--th-radius-sm) var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.safe-payload dt {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.safe-payload dd {
|
||||
margin: 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-order-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -548,14 +413,19 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.card-meta-grid,
|
||||
.task-card-section__header,
|
||||
.review-box {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
.task-card-section__header,
|
||||
.card-secondary-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-card-section__status-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user