Files
Wyndham-RSVN/client/src/views/reservation/ReservationOrderDetailView.vue
T
2026-07-20 15:16:21 +07:00

766 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="th-page order-detail-view">
<div
v-if="loading"
class="th-section order-state"
>
{{ t('common.loading') }}
</div>
<div
v-else-if="errorMessage"
class="th-section order-state order-state--error"
>
{{ errorMessage }}
</div>
<template v-else-if="detail">
<section class="th-section order-summary">
<header>
<div>
<p>{{ t('order.summary') }}</p>
<h1>{{ detail.order.display_name }}</h1>
</div>
<ReservationStatusBadge :status="detail.order.order_status" />
</header>
<dl>
<div>
<dt>{{ t('order.groupCode') }}</dt>
<dd>{{ detail.order.group_code ?? '-' }}</dd>
</div>
<div>
<dt>{{ t('order.confirmationNumber') }}</dt>
<dd>{{ detail.order.confirmation_number ?? '-' }}</dd>
</div>
<div>
<dt>{{ t('order.temporaryOrderNo') }}</dt>
<dd>{{ detail.order.temporary_order_no ?? '-' }}</dd>
</div>
<div>
<dt>{{ t('order.blockCode') }}</dt>
<dd>{{ detail.order.block_code ?? '-' }}</dd>
</div>
<div>
<dt>{{ t('order.createdAt') }}</dt>
<dd>{{ formatReservationDateTime(detail.order.created_at) }}</dd>
</div>
<div>
<dt>{{ t('order.updatedAt') }}</dt>
<dd>{{ formatReservationDateTime(detail.order.updated_at) }}</dd>
</div>
</dl>
</section>
<div class="overview-layout">
<section class="th-section overview-section">
<div class="th-section-header">
<div>
<h2 class="th-section-title">
{{ t('order.v4OverviewTitle') }}
</h2>
<p class="th-muted">
{{ t('order.v4OverviewSubtitle') }}
</p>
</div>
</div>
<template v-if="hasOrderOverview">
<dl class="overview-facts">
<div
v-for="fact in overviewFacts"
:key="fact.label"
>
<dt>{{ fact.label }}</dt>
<dd>{{ fact.value }}</dd>
</div>
</dl>
<div class="room-summary">
<span>{{ t('order.roomItems') }}</span>
<div
v-if="overviewRoomItems.length"
class="room-summary__items"
>
<strong
v-for="room in overviewRoomItems"
:key="`${room.room_type_code ?? '-'}-${room.room_count ?? '-'}`"
>
{{ formatRoomSummary(room) }}
</strong>
</div>
<strong v-else>-</strong>
</div>
<div class="overview-statuses">
<div
v-for="statusItem in overviewStatuses"
:key="statusItem.label"
>
<span>{{ statusItem.label }}</span>
<ReservationStatusBadge
v-if="statusItem.status"
:status="statusItem.status"
/>
<strong v-else>-</strong>
</div>
</div>
</template>
<p
v-else
class="empty-copy"
>
{{ t('order.v4OverviewEmpty') }}
</p>
</section>
<section class="th-section next-action-section">
<div class="th-section-header">
<div>
<h2 class="th-section-title">
{{ t('order.nextV4ActionTitle') }}
</h2>
<p class="th-muted">
{{ t('order.nextV4ActionSubtitle') }}
</p>
</div>
</div>
<template v-if="hasNextV4Action && nextV4Action">
<dl class="next-action-facts">
<div>
<dt>{{ t('order.nextV4ActionType') }}</dt>
<dd>{{ formatNextActionType(nextV4Action.action_type) }}</dd>
</div>
<div>
<dt>{{ t('order.nextV4ActionStatus') }}</dt>
<dd>
<ReservationStatusBadge
v-if="nextV4Action.action_status"
:status="nextV4Action.action_status"
/>
<span v-else>-</span>
</dd>
</div>
<div>
<dt>{{ t('order.openOrderTaskCount') }}</dt>
<dd>{{ nextV4Action.open_order_task_count ?? 0 }}</dd>
</div>
<div>
<dt>{{ t('order.nextV4CardId') }}</dt>
<dd>{{ nextV4Action.card_id ?? '-' }}</dd>
</div>
</dl>
<RouterLink
class="primary-link"
:to="`/reservation/order-tasks/${nextV4Action.order_task_id}`"
>
{{ t('order.enterV4Task') }}
</RouterLink>
</template>
<p
v-else
class="empty-copy"
>
{{ t('order.nextV4ActionEmpty') }}
</p>
</section>
</div>
<section class="th-section source-section">
<div class="th-section-header">
<div>
<h2 class="th-section-title">
{{ t('order.relatedSourceMessages') }}
</h2>
<p class="th-muted">
{{ t('order.relatedSourceMessagesHint') }}
</p>
</div>
</div>
<div
v-if="relatedSourceMessages.length"
class="source-list"
>
<article
v-for="sourceMessage in relatedSourceMessages"
:key="sourceMessage.source_message_id"
class="source-item"
>
<div>
<strong>{{ sourceMessage.subject ?? t('taskList.noSourceSubject') }}</strong>
<span>{{ sourceMessage.sender_summary ?? '-' }}</span>
</div>
<dl>
<div>
<dt>{{ t('task.sourceReceivedAt') }}</dt>
<dd>{{ formatReservationDateTime(sourceMessage.received_at) }}</dd>
</div>
<div>
<dt>{{ t('order.conversationMessageCount') }}</dt>
<dd>{{ sourceMessage.conversation_message_count ?? '-' }}</dd>
</div>
<div>
<dt>{{ t('order.externalConversationId') }}</dt>
<dd>{{ sourceMessage.external_conversation_id ?? '-' }}</dd>
</div>
</dl>
<RouterLink
class="source-link"
:to="`/reservation/source-messages/${sourceMessage.source_message_id}/conversation`"
>
{{ t('task.viewConversation') }}
</RouterLink>
</article>
</div>
<p
v-else
class="empty-copy"
>
{{ t('order.relatedSourceMessagesEmpty') }}
</p>
</section>
<section class="th-section v4-timeline-section">
<div class="th-section-header">
<div>
<h2 class="th-section-title">
{{ t('order.v4TaskTimeline') }}
</h2>
<p class="th-muted">
{{ t('order.v4TaskTimelineHint') }}
</p>
</div>
</div>
<p
v-if="!v4OrderTasks.length"
class="empty-copy"
>
{{ t('order.v4TaskTimelineEmpty') }}
</p>
<div
v-else
class="v4-timeline-list"
>
<article
v-for="item in v4OrderTasks"
:key="item.order_task_id"
class="v4-timeline-item"
>
<RouterLink
class="v4-timeline-item__main"
:to="`/reservation/order-tasks/${item.order_task_id}`"
>
<span>
<strong>{{ v4OrderReference(item) }}</strong>
<small>{{ item.source_message_summary?.subject ?? t('taskList.noSourceSubject') }}</small>
</span>
<ReservationStatusBadge :status="item.order_task_status" />
</RouterLink>
<dl class="timeline-meta">
<div>
<dt>{{ t('taskList.cardCountsTotal', { total: item.card_counts.total_count }) }}</dt>
<dd>
{{ t('taskList.cardCountsPending', { count: item.card_counts.pending_confirm_count }) }}
·
{{ t('taskList.cardCountsReview', { count: item.card_counts.review_required_count }) }}
·
{{ t('taskList.cardCountsConfirmed', { count: item.card_counts.confirmed_count }) }}
</dd>
</div>
<div>
<dt>{{ t('task.sourceReceivedAt') }}</dt>
<dd>{{ formatReservationDateTime(item.source_received_at) }}</dd>
</div>
<div>
<dt>{{ t('order.latestActivityAt') }}</dt>
<dd>{{ formatReservationDateTime(item.latest_activity_at ?? item.updated_at ?? item.created_at) }}</dd>
</div>
</dl>
<div
v-if="item.cards.length"
class="card-summary-list"
>
<article
v-for="card in item.cards"
:key="card.card_id"
class="card-summary"
>
<div class="card-summary__heading">
<span>
<strong>{{ cardTimelineTitle(card) }}</strong>
<small>{{ card.event_type ?? '-' }}</small>
</span>
<ReservationStatusBadge :status="card.card_status" />
</div>
<dl>
<div>
<dt>{{ t('order.cardId') }}</dt>
<dd>{{ card.card_id }}</dd>
</div>
<div>
<dt>{{ t('order.reviewStatus') }}</dt>
<dd>
<ReservationStatusBadge
v-if="card.review_status"
:status="card.review_status"
/>
<span v-else>-</span>
</dd>
</div>
<div>
<dt>{{ t('order.confirmedBy') }}</dt>
<dd>{{ card.confirmed_by ?? '-' }}</dd>
</div>
<div>
<dt>{{ t('order.confirmedAt') }}</dt>
<dd>{{ formatReservationDateTime(card.confirmed_at) }}</dd>
</div>
<div>
<dt>{{ t('order.latestActivityAt') }}</dt>
<dd>{{ formatReservationDateTime(card.latest_activity_at) }}</dd>
</div>
</dl>
</article>
</div>
<p
v-else
class="empty-copy empty-copy--compact"
>
{{ t('order.v4CardsEmpty') }}
</p>
</article>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import { fetchReservationOrderDetail } from '@/services/reservationService'
import { useAuthStore } from '@/stores/authStore'
import type {
ReservationOrderDetailResult,
ReservationOrderV4NextActionResult,
ReservationOrderV4RoomSummary,
ReservationOrderV4TaskCardTimelineItem,
ReservationV4OrderTaskTimelineItem,
} from '@/types/reservation'
import { formatReservationTaskCard } from '@/utils/reservationDisplay'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const route = useRoute()
const { t } = useI18n()
const authStore = useAuthStore()
const loading = ref(false)
const errorMessage = ref('')
const detail = ref<ReservationOrderDetailResult | null>(null)
const orderId = computed(() => String(route.params.orderId ?? ''))
const orderOverview = computed(() => detail.value?.order_overview ?? null)
const nextV4Action = computed(() => detail.value?.next_v4_action ?? null)
const relatedSourceMessages = computed(() => detail.value?.related_source_messages ?? [])
const v4OrderTasks = computed(() => detail.value?.v4_order_tasks ?? [])
const overviewRoomItems = computed(() => orderOverview.value?.room_items ?? [])
const hasNextV4Action = computed(() => {
const action = nextV4Action.value
return Boolean(action?.order_task_id && action.action_type && action.action_type !== 'NONE')
})
const hasOrderOverview = computed(() => {
const overview = orderOverview.value
if (!overview) {
return false
}
return Boolean(
overview.account_code ||
overview.account_name ||
overview.market_code ||
overview.source_code ||
overview.arrival_date ||
overview.departure_date ||
overview.rate_code ||
overview.room_items.length ||
overview.trace_card_status ||
overview.rooming_list_card_status ||
overview.payment_card_status ||
overview.latest_confirmed_at,
)
})
const overviewFacts = computed(() => {
const overview = orderOverview.value
if (!overview) {
return []
}
return [
{ label: t('order.accountCode'), value: displayValue(overview.account_code) },
{ label: t('order.accountName'), value: displayValue(overview.account_name) },
{ label: t('order.marketCode'), value: displayValue(overview.market_code) },
{ label: t('order.sourceCode'), value: displayValue(overview.source_code) },
{ label: t('order.arrivalDate'), value: displayValue(overview.arrival_date) },
{ label: t('order.departureDate'), value: displayValue(overview.departure_date) },
{ label: t('order.rateCode'), value: displayValue(overview.rate_code) },
{ label: t('order.latestConfirmedAt'), value: formatReservationDateTime(overview.latest_confirmed_at) },
]
})
const overviewStatuses = computed(() => {
const overview = orderOverview.value
return [
{ label: t('order.traceCardStatus'), status: overview?.trace_card_status ?? null },
{ label: t('order.roomingListCardStatus'), status: overview?.rooming_list_card_status ?? null },
{ label: t('order.paymentCardStatus'), status: overview?.payment_card_status ?? null },
]
})
watch(
orderId,
(nextOrderId) => {
void loadOrder(nextOrderId)
},
{ immediate: true },
)
watch(
() => authStore.selectedHotelId,
(nextHotelId, previousHotelId) => {
if (!nextHotelId || nextHotelId === previousHotelId) {
return
}
void loadOrder(orderId.value)
},
)
async function loadOrder(nextOrderId: string): Promise<void> {
loading.value = true
errorMessage.value = ''
try {
detail.value = await fetchReservationOrderDetail(nextOrderId)
} catch (error) {
detail.value = null
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
loading.value = false
}
}
function displayValue(value: string | null | undefined): string {
return value?.trim() || '-'
}
function formatRoomSummary(room: ReservationOrderV4RoomSummary): string {
return `${displayValue(room.room_type_code)} × ${room.room_count ?? '-'}`
}
function formatNextActionType(actionType: ReservationOrderV4NextActionResult['action_type']): string {
if (actionType === 'CONFIRM') {
return t('order.nextActionConfirm')
}
if (actionType === 'REVIEW') {
return t('order.nextActionReview')
}
if (actionType === 'NONE') {
return t('order.nextActionNone')
}
return actionType ?? '-'
}
function v4OrderReference(item: ReservationV4OrderTaskTimelineItem): string {
return item.order_ref?.trim() || item.source_message_summary?.subject?.trim() || `#${item.order_task_id}`
}
function cardTimelineTitle(card: ReservationOrderV4TaskCardTimelineItem): string {
const title = formatReservationTaskCard(t, card.card_type, card.card_type)
const index = card.source_event_index == null ? '' : ` · ${t('order.sourceEventIndex', { index: card.source_event_index })}`
return `${title}${index}`
}
</script>
<style scoped>
.order-detail-view {
max-width: 1320px;
}
.order-state {
min-height: 260px;
display: grid;
place-items: center;
color: var(--th-color-slate-500);
font-weight: 700;
}
.order-state--error {
color: var(--th-color-danger);
}
.order-summary,
.overview-section,
.next-action-section,
.source-section,
.v4-timeline-section {
display: grid;
gap: 18px;
padding: 20px;
}
.order-summary header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.order-summary p,
.order-summary h1,
.order-summary dl,
.empty-copy,
.th-muted {
margin: 0;
}
.order-summary p {
color: var(--th-color-blue-600);
font-size: 12px;
font-weight: 800;
}
.order-summary h1 {
margin-top: 4px;
color: var(--th-color-slate-900);
font-size: 26px;
}
.order-summary dl,
.overview-facts,
.next-action-facts,
.source-item dl,
.timeline-meta,
.card-summary dl {
display: grid;
gap: 16px;
}
.order-summary dl,
.overview-facts {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.next-action-facts,
.source-item dl,
.timeline-meta {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.card-summary dl {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.order-summary dt,
.overview-facts dt,
.next-action-facts dt,
.source-item dt,
.timeline-meta dt,
.card-summary dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
}
.order-summary dd,
.overview-facts dd,
.next-action-facts dd,
.source-item dd,
.timeline-meta dd,
.card-summary dd {
margin: 5px 0 0;
color: var(--th-color-slate-900);
font-size: 13px;
font-weight: 800;
overflow-wrap: anywhere;
}
.overview-layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(300px, 0.9fr);
gap: 18px;
align-items: start;
}
.th-muted,
.empty-copy {
color: var(--th-color-slate-500);
font-size: 13px;
line-height: 1.6;
}
.empty-copy--compact {
font-size: 12px;
}
.room-summary,
.overview-statuses {
display: grid;
gap: 8px;
border-top: 1px solid var(--th-color-slate-100);
padding-top: 14px;
}
.room-summary > span,
.overview-statuses span {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.room-summary__items,
.overview-statuses {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.room-summary__items strong {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-900);
padding: 5px 10px;
font-size: 13px;
}
.overview-statuses div {
display: inline-flex;
align-items: center;
gap: 8px;
}
.primary-link,
.source-link {
color: var(--th-color-blue-600);
font-size: 13px;
font-weight: 800;
text-decoration: none;
}
.primary-link {
width: fit-content;
border-radius: var(--th-radius-sm);
background: var(--th-color-blue-600);
color: var(--th-color-white);
padding: 9px 12px;
}
.source-list,
.v4-timeline-list,
.card-summary-list {
display: grid;
gap: 12px;
}
.source-item,
.v4-timeline-item,
.card-summary {
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;
}
.source-item > div,
.v4-timeline-item__main,
.card-summary__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.source-item > div {
align-items: center;
}
.source-item > div > strong,
.source-item > div > span,
.v4-timeline-item__main span,
.card-summary__heading span {
min-width: 0;
}
.source-item > div > strong,
.v4-timeline-item__main strong,
.card-summary__heading strong {
color: var(--th-color-slate-900);
font-size: 14px;
overflow-wrap: anywhere;
}
.source-item > div > span,
.v4-timeline-item__main small,
.card-summary__heading small {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
overflow-wrap: anywhere;
}
.v4-timeline-item__main {
align-items: center;
color: inherit;
text-decoration: none;
}
.v4-timeline-item__main span,
.card-summary__heading span {
display: grid;
gap: 3px;
}
.card-summary-list {
border-top: 1px solid var(--th-color-slate-100);
padding-top: 12px;
}
.card-summary {
background: var(--th-color-slate-50);
}
@media (max-width: 1100px) {
.overview-layout {
grid-template-columns: 1fr;
}
.order-summary dl,
.overview-facts,
.next-action-facts,
.source-item dl,
.timeline-meta,
.card-summary dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
.order-summary dl,
.overview-facts,
.next-action-facts,
.source-item dl,
.timeline-meta,
.card-summary dl {
grid-template-columns: 1fr;
}
.source-item > div,
.v4-timeline-item__main,
.card-summary__heading {
display: grid;
}
}
</style>