接入M002V4订单任务多卡前端
This commit is contained in:
1 parent
13364e7edf
commit
cae169cefd
19 files changed
+4084
-343
No files matched your search
@@ -32,82 +32,43 @@
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('taskList.taskType') }}</span>
|
||||
<span>{{ t('taskList.itemType') }}</span>
|
||||
<select
|
||||
v-model="filters.task_type"
|
||||
name="task_type"
|
||||
@change="resetToFirstPage"
|
||||
v-model="filters.item_type"
|
||||
name="item_type"
|
||||
@change="changeItemType"
|
||||
>
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="NEW_BOOKING">{{ t('cardType.NEW_BOOKING') }}</option>
|
||||
<option value="UPDATE_BOOKING">{{ t('cardType.UPDATE_BOOKING') }}</option>
|
||||
<option value="CANCEL_BOOKING">{{ t('cardType.CANCEL_BOOKING') }}</option>
|
||||
<option value="MANUAL_REVIEW">{{ t('cardType.MANUAL_REVIEW') }}</option>
|
||||
<option value="SOURCE_MESSAGE_ONLY">{{ t('cardType.SOURCE_MESSAGE_ONLY') }}</option>
|
||||
<option value="ORDER_TASK">{{ t('taskList.itemTypeOrderTask') }}</option>
|
||||
<option value="SOURCE_NOTIFICATION">{{ t('taskList.itemTypeSourceNotification') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('taskList.taskSubtype') }}</span>
|
||||
<span>{{ t('taskList.orderTaskStatus') }}</span>
|
||||
<select
|
||||
v-model="filters.task_subtype"
|
||||
name="task_subtype"
|
||||
v-model="filters.order_task_status"
|
||||
name="order_task_status"
|
||||
:disabled="!isOrderTaskMode"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="NEW_BOOKING">{{ t('taskSubtype.NEW_BOOKING') }}</option>
|
||||
<option value="UPDATE_BOOKING">{{ t('taskSubtype.UPDATE_BOOKING') }}</option>
|
||||
<option value="CANCEL_BOOKING">{{ t('taskSubtype.CANCEL_BOOKING') }}</option>
|
||||
<option value="RATE_CHANGE">{{ t('taskSubtype.RATE_CHANGE') }}</option>
|
||||
<option value="UPDATE_STAY_DATES">{{ t('taskSubtype.UPDATE_STAY_DATES') }}</option>
|
||||
<option value="UPDATE_ROOM_TYPE">{{ t('taskSubtype.UPDATE_ROOM_TYPE') }}</option>
|
||||
<option value="UPDATE_FIX_CHARGE">{{ t('taskSubtype.UPDATE_FIX_CHARGE') }}</option>
|
||||
<option value="NEW_GROUP_BLOCK">{{ t('taskSubtype.NEW_GROUP_BLOCK') }}</option>
|
||||
<option value="NEW_FIT_RESERVATION">{{ t('taskSubtype.NEW_FIT_RESERVATION') }}</option>
|
||||
<option value="MANUAL_REVIEW">{{ t('taskSubtype.MANUAL_REVIEW') }}</option>
|
||||
<option value="S10">{{ t('taskSubtype.S10') }}</option>
|
||||
<option value="S99">{{ t('taskSubtype.S99') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('workbench.taskStatus') }}</span>
|
||||
<select
|
||||
v-model="filters.task_status"
|
||||
name="task_status"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="PENDING_CONFIRM">{{ t('status.PENDING_CONFIRM') }}</option>
|
||||
<option value="READY">{{ t('status.READY') }}</option>
|
||||
<option value="EXECUTING">{{ t('status.EXECUTING') }}</option>
|
||||
<option value="FAILED">{{ t('status.FAILED') }}</option>
|
||||
<option value="BLOCKED">{{ t('status.BLOCKED') }}</option>
|
||||
<option value="OPEN">{{ t('status.OPEN') }}</option>
|
||||
<option value="COMPLETED">{{ t('status.COMPLETED') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('workbench.orderStatus') }}</span>
|
||||
<span>{{ t('taskList.cardStatus') }}</span>
|
||||
<select
|
||||
v-model="filters.order_status"
|
||||
name="order_status"
|
||||
v-model="filters.card_status"
|
||||
name="card_status"
|
||||
:disabled="!isOrderTaskMode"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option value="">{{ t('common.all') }}</option>
|
||||
<option value="TEMPORARY">{{ t('status.TEMPORARY') }}</option>
|
||||
<option value="ACTIVE">{{ t('status.ACTIVE') }}</option>
|
||||
<option value="ENDED">{{ t('status.ENDED') }}</option>
|
||||
<option value="LOGIC_DELETED">{{ t('status.LOGIC_DELETED') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('taskList.queueParticipation') }}</span>
|
||||
<select
|
||||
v-model="filters.queue_participation"
|
||||
name="queue_participation"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option :value="undefined">{{ t('taskList.queueAll') }}</option>
|
||||
<option :value="true">{{ t('taskList.queueIncluded') }}</option>
|
||||
<option :value="false">{{ t('taskList.queueExcluded') }}</option>
|
||||
<option value="PENDING_CONFIRM">{{ t('status.PENDING_CONFIRM') }}</option>
|
||||
<option value="REVIEW_REQUIRED">{{ t('status.REVIEW_REQUIRED') }}</option>
|
||||
<option value="CONFIRMED">{{ t('status.CONFIRMED') }}</option>
|
||||
<option value="READONLY">{{ t('status.READONLY') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -138,7 +99,7 @@
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!tasks.length"
|
||||
v-else-if="!items.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('common.noData') }}
|
||||
@@ -151,65 +112,79 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('taskList.columns.task') }}</th>
|
||||
<th>{{ t('taskList.columns.item') }}</th>
|
||||
<th>{{ t('taskList.columns.order') }}</th>
|
||||
<th>{{ t('taskList.columns.card') }}</th>
|
||||
<th>{{ t('taskList.columns.status') }}</th>
|
||||
<th>{{ t('taskList.columns.source') }}</th>
|
||||
<th>{{ t('taskList.columns.queue') }}</th>
|
||||
<th>{{ t('taskList.columns.cards') }}</th>
|
||||
<th>{{ t('workbench.columns.operation') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="task in tasks"
|
||||
:key="task.task_id"
|
||||
v-for="item in items"
|
||||
:key="`${item.item_type}-${item.target_id}`"
|
||||
>
|
||||
<td>
|
||||
<strong class="th-code">#{{ task.task_id }}</strong>
|
||||
<small>{{ formatReservationTaskRouteSummary(t, task) }}</small>
|
||||
<strong>{{ itemKindLabel(item) }}</strong>
|
||||
<small class="th-code">#{{ item.target_id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="isReadonlyDiagnosticTask(task)">
|
||||
<strong>{{ t('taskList.sourceOnlyOrderLabel') }}</strong>
|
||||
<small>{{ t('taskList.hiddenTechnicalOrder') }}</small>
|
||||
<template v-if="item.item_type === 'ORDER_TASK'">
|
||||
<strong>{{ item.display_order_key ?? t('taskList.orderUnresolved') }}</strong>
|
||||
<small
|
||||
v-if="item.order_id"
|
||||
class="th-code"
|
||||
>{{ item.order_id }}</small>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>{{ task.display_order_key ?? task.temporary_order_no ?? task.order_id }}</strong>
|
||||
<small class="th-code">{{ task.order_id }}</small>
|
||||
<strong>{{ t('taskList.sourceNotificationNoOrder') }}</strong>
|
||||
<small>{{ t('taskList.sourceNotificationNoOrderHint') }}</small>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ formatReservationTaskCardLabel(t, task) }}</span>
|
||||
<small v-if="task.result_type">{{ formatReservationResultType(t, task.result_type) }}</small>
|
||||
</td>
|
||||
<td><ReservationStatusBadge :status="task.task_status" /></td>
|
||||
<td>
|
||||
<span>{{ task.source_subject ?? t('taskList.noSourceSubject') }}</span>
|
||||
<small>{{ task.source_sender_summary ?? task.source_message_id ?? '-' }}</small>
|
||||
<small>{{ t('taskList.sourceReceivedAt') }}: {{ formatReservationDateTime(task.source_received_at) }}</small>
|
||||
<ReservationStatusBadge :status="displayStatus(item)" />
|
||||
<small v-if="item.readonly_reason_code">
|
||||
{{ formatReservationReadonlyReason(t, item.readonly_reason_code) }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ task.queue_sequence ?? '-' }}</span>
|
||||
<small>{{ task.can_process ? t('taskList.canProcess') : formatReservationReadonlyReason(t, task.readonly_reason_code) }}</small>
|
||||
<span>{{ sourceSubject(item) }}</span>
|
||||
<small>{{ sourceSender(item) }}</small>
|
||||
<small>{{ t('taskList.sourceReceivedAt') }}: {{ formatReservationDateTime(sourceReceivedAt(item)) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="item.card_counts">
|
||||
<span>{{ t('taskList.cardCountsTotal', { total: item.card_counts.total_count }) }}</span>
|
||||
<small>
|
||||
{{ 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 }) }}
|
||||
</small>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.notification_status ? formatStatusLabel(item.notification_status) : '-' }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<RouterLink
|
||||
class="row-action"
|
||||
:to="`/reservation/tasks/${task.task_id}`"
|
||||
:to="detailPath(item)"
|
||||
>
|
||||
{{ t('taskList.viewTask') }}
|
||||
{{ item.item_type === 'SOURCE_NOTIFICATION' ? t('taskList.viewNotification') : t('taskList.viewTask') }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="!isReadonlyDiagnosticTask(task)"
|
||||
v-if="item.order_id"
|
||||
class="row-action"
|
||||
:to="`/reservation/orders/${task.order_id}`"
|
||||
:to="`/reservation/orders/${item.order_id}`"
|
||||
>
|
||||
{{ t('taskList.viewOrder') }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="!task.source_message_id"
|
||||
v-if="!item.source_message_id"
|
||||
class="pending-link"
|
||||
>
|
||||
{{ t('common.endpointMissing') }}
|
||||
@@ -217,7 +192,7 @@
|
||||
<RouterLink
|
||||
v-else
|
||||
class="row-action"
|
||||
:to="`/reservation/source-messages/${task.source_message_id}/conversation`"
|
||||
:to="`/reservation/source-messages/${item.source_message_id}/conversation`"
|
||||
>
|
||||
{{ t('taskList.viewConversation') }}
|
||||
</RouterLink>
|
||||
@@ -270,35 +245,53 @@ import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import { fetchReservationTaskList } from '@/services/reservationService'
|
||||
import {
|
||||
fetchReservationV4OrderTasks,
|
||||
fetchReservationV4WorkbenchItems,
|
||||
} from '@/services/reservationService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { ReservationPageResult, ReservationTaskListFilters, ReservationTaskListItem } from '@/types/reservation'
|
||||
import type {
|
||||
ReservationPageResult,
|
||||
ReservationV4OrderTaskListFilters,
|
||||
ReservationV4OrderTaskListItem,
|
||||
ReservationV4WorkbenchFilters,
|
||||
ReservationV4WorkbenchItem,
|
||||
ReservationV4WorkbenchItemType,
|
||||
} from '@/types/reservation'
|
||||
import {
|
||||
formatReservationReadonlyReason,
|
||||
formatReservationResultType,
|
||||
formatReservationTaskCardLabel,
|
||||
formatReservationTaskRouteSummary,
|
||||
isReservationReadonlyDiagnosticTask,
|
||||
} from '@/utils/reservationDisplay'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
type WorkbenchItemFilter = ReservationV4WorkbenchItemType | ''
|
||||
type WorkbenchRow = ReservationV4WorkbenchItem & {
|
||||
order_id?: string | null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const tasks = ref<ReservationTaskListItem[]>([])
|
||||
const items = ref<WorkbenchRow[]>([])
|
||||
const page = ref(createPage(1, 20, 0))
|
||||
const total = computed(() => page.value.total)
|
||||
const currentPage = computed(() => page.value.page_num)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(page.value.total / Math.max(page.value.page_size, 1))))
|
||||
const isOrderTaskMode = computed(() => filters.item_type === 'ORDER_TASK')
|
||||
let taskListRequestSequence = 0
|
||||
const filters = reactive<ReservationTaskListFilters>({
|
||||
|
||||
const filters = reactive<{
|
||||
keyword: string
|
||||
item_type: WorkbenchItemFilter
|
||||
order_task_status: '' | 'OPEN' | 'COMPLETED'
|
||||
card_status: '' | 'READONLY' | 'PENDING_CONFIRM' | 'REVIEW_REQUIRED' | 'CONFIRMED'
|
||||
page_num: number
|
||||
page_size: number
|
||||
}>({
|
||||
keyword: '',
|
||||
task_type: '',
|
||||
task_subtype: '',
|
||||
task_status: '',
|
||||
order_status: '',
|
||||
queue_participation: undefined,
|
||||
item_type: '',
|
||||
order_task_status: '',
|
||||
card_status: '',
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
@@ -326,18 +319,20 @@ async function loadTasks(): Promise<void> {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await fetchReservationTaskList({ ...filters })
|
||||
const result = isOrderTaskMode.value
|
||||
? await fetchReservationV4OrderTasks(buildOrderTaskFilters())
|
||||
: await fetchReservationV4WorkbenchItems(buildWorkbenchFilters())
|
||||
if (requestSequence !== taskListRequestSequence) {
|
||||
return
|
||||
}
|
||||
tasks.value = result.items
|
||||
items.value = result.items.map((item) => 'order_task' in item ? toWorkbenchRowFromOrderTask(item) : item)
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
if (requestSequence !== taskListRequestSequence) {
|
||||
return
|
||||
}
|
||||
tasks.value = []
|
||||
page.value = createPage(filters.page_num ?? 1, filters.page_size ?? 20, 0)
|
||||
items.value = []
|
||||
page.value = createPage(filters.page_num, filters.page_size, 0)
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
if (requestSequence === taskListRequestSequence) {
|
||||
@@ -346,17 +341,63 @@ async function loadTasks(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkbenchFilters(): ReservationV4WorkbenchFilters {
|
||||
return {
|
||||
keyword: filters.keyword,
|
||||
item_type: filters.item_type,
|
||||
page_num: filters.page_num,
|
||||
page_size: filters.page_size,
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrderTaskFilters(): ReservationV4OrderTaskListFilters {
|
||||
return {
|
||||
keyword: filters.keyword,
|
||||
order_task_status: filters.order_task_status,
|
||||
card_status: filters.card_status,
|
||||
page_num: filters.page_num,
|
||||
page_size: filters.page_size,
|
||||
}
|
||||
}
|
||||
|
||||
function toWorkbenchRowFromOrderTask(item: ReservationV4OrderTaskListItem): WorkbenchRow {
|
||||
return {
|
||||
item_type: 'ORDER_TASK',
|
||||
target_id: item.order_task.order_task_id,
|
||||
hotel_id: item.order_task.hotel_id,
|
||||
source_message_id: item.order_task.source_message_id,
|
||||
source_message_summary: item.source_message_summary,
|
||||
source_received_at: item.order_task.source_received_at,
|
||||
display_order_key: item.order_task.display_order_key,
|
||||
card_counts: item.card_counts,
|
||||
next_action_card_id: item.next_action_card_id,
|
||||
notification_status: null,
|
||||
order_task_status: item.order_task.order_task_status,
|
||||
display_status: item.display_status,
|
||||
readonly_reason_code: item.availability.readonly_reason_code,
|
||||
created_at: item.order_task.created_at,
|
||||
updated_at: item.order_task.updated_at,
|
||||
order_id: item.order_task.order_id,
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.task_type = ''
|
||||
filters.task_subtype = ''
|
||||
filters.task_status = ''
|
||||
filters.order_status = ''
|
||||
filters.queue_participation = undefined
|
||||
filters.item_type = ''
|
||||
filters.order_task_status = ''
|
||||
filters.card_status = ''
|
||||
filters.page_num = 1
|
||||
filters.page_size = 20
|
||||
}
|
||||
|
||||
function changeItemType(): void {
|
||||
if (!isOrderTaskMode.value) {
|
||||
filters.order_task_status = ''
|
||||
filters.card_status = ''
|
||||
}
|
||||
resetToFirstPage()
|
||||
}
|
||||
|
||||
function resetToFirstPage(): void {
|
||||
filters.page_num = 1
|
||||
}
|
||||
@@ -373,8 +414,37 @@ function goToPage(page: number): void {
|
||||
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
||||
}
|
||||
|
||||
function isReadonlyDiagnosticTask(task: ReservationTaskListItem): boolean {
|
||||
return isReservationReadonlyDiagnosticTask(task)
|
||||
function itemKindLabel(item: WorkbenchRow): string {
|
||||
return item.item_type === 'SOURCE_NOTIFICATION'
|
||||
? t('taskList.itemTypeSourceNotification')
|
||||
: t('taskList.itemTypeOrderTask')
|
||||
}
|
||||
|
||||
function displayStatus(item: WorkbenchRow): string {
|
||||
return item.display_status ?? item.notification_status ?? item.order_task_status ?? 'OPEN'
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: string): string {
|
||||
return t(`status.${status}`, status)
|
||||
}
|
||||
|
||||
function detailPath(item: WorkbenchRow): string {
|
||||
if (item.item_type === 'SOURCE_NOTIFICATION') {
|
||||
return `/reservation/source-notifications/${item.target_id}`
|
||||
}
|
||||
return `/reservation/order-tasks/${item.target_id}`
|
||||
}
|
||||
|
||||
function sourceSubject(item: WorkbenchRow): string {
|
||||
return item.source_message_summary?.subject ?? t('taskList.noSourceSubject')
|
||||
}
|
||||
|
||||
function sourceSender(item: WorkbenchRow): string {
|
||||
return item.source_message_summary?.sender_summary ?? item.source_message_id ?? '-'
|
||||
}
|
||||
|
||||
function sourceReceivedAt(item: WorkbenchRow): string | null {
|
||||
return item.source_received_at ?? item.source_message_summary?.received_at ?? null
|
||||
}
|
||||
|
||||
function createPage(pageNum: number, pageSize: number, total: number): ReservationPageResult {
|
||||
@@ -413,7 +483,7 @@ function createPage(pageNum: number, pageSize: number, total: number): Reservati
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px 20px 0;
|
||||
}
|
||||
@@ -446,13 +516,6 @@ function createPage(pageNum: number, pageSize: number, total: number): Reservati
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.filter-hint {
|
||||
color: var(--th-color-warning);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.text-button,
|
||||
.row-action {
|
||||
border: 0;
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
<template>
|
||||
<div class="th-page v4-task-detail-view">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>{{ t('taskV4.title') }}</h1>
|
||||
<p>{{ t('taskV4.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="loading"
|
||||
@click="loadDetail"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="errorMessage"
|
||||
class="empty-state empty-state--error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<template v-else-if="detail">
|
||||
<section class="th-section summary-section">
|
||||
<div class="th-section-header">
|
||||
<div>
|
||||
<h2 class="th-section-title">
|
||||
{{ t('taskV4.orderTaskSummary') }}
|
||||
</h2>
|
||||
<p class="th-muted">
|
||||
#{{ detail.order_task.order_task_id }}
|
||||
</p>
|
||||
</div>
|
||||
<ReservationStatusBadge :status="detail.order_task.order_task_status" />
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div>
|
||||
<span>{{ t('taskV4.orderReference') }}</span>
|
||||
<strong>{{ displayOrderReference }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('taskV4.targetBookingType') }}</span>
|
||||
<strong>{{ detail.order_task.target_booking_type ?? '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('taskV4.targetLocator') }}</span>
|
||||
<strong>{{ targetLocator }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('taskV4.targetResolutionStatus') }}</span>
|
||||
<strong>{{ detail.order_task.target_resolution_status ?? '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('taskV4.cardCounts') }}</span>
|
||||
<strong>
|
||||
{{ t('taskList.cardCountsTotal', { total: detail.card_counts.total_count }) }}
|
||||
</strong>
|
||||
<small>
|
||||
{{ t('taskList.cardCountsPending', { count: detail.card_counts.pending_confirm_count }) }}
|
||||
·
|
||||
{{ t('taskList.cardCountsReview', { count: detail.card_counts.review_required_count }) }}
|
||||
·
|
||||
{{ t('taskList.cardCountsConfirmed', { count: detail.card_counts.confirmed_count }) }}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('task.sourceReceivedAt') }}</span>
|
||||
<strong>{{ formatReservationDateTime(detail.order_task.source_received_at) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ReservationV4SourceMessageCard
|
||||
:title="t('taskV4.sourceMessageCard')"
|
||||
:source-message-summary="detail.source_message_summary"
|
||||
:source-card="detail.source_message_card"
|
||||
/>
|
||||
|
||||
<TaskCardSection
|
||||
v-if="detail.basic_information_card"
|
||||
:card="detail.basic_information_card"
|
||||
:title="t('taskV4.basicInformationCard')"
|
||||
/>
|
||||
|
||||
<section class="card-stack">
|
||||
<div class="th-section-header card-stack__header">
|
||||
<div>
|
||||
<h2 class="th-section-title">
|
||||
{{ t('taskV4.businessCards') }}
|
||||
</h2>
|
||||
<p class="th-muted">
|
||||
{{ t('taskList.cardCountsTotal', { total: detail.business_cards.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!detail.business_cards.length"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('taskV4.noBusinessCards') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<TaskCardSection
|
||||
v-for="businessCard in detail.business_cards"
|
||||
:key="businessCard.card_id"
|
||||
:card="businessCard"
|
||||
:title="cardTitle(businessCard)"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="th-section diagnostics-section">
|
||||
<div class="th-section-header">
|
||||
<div>
|
||||
<h2 class="th-section-title">
|
||||
{{ t('taskV4.adapterContractErrors') }}
|
||||
</h2>
|
||||
<p class="th-muted">
|
||||
{{ t('taskV4.noAdapterContractErrors') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="detail.adapter_contract_errors.length"
|
||||
class="diagnostic-list"
|
||||
>
|
||||
<article
|
||||
v-for="error in detail.adapter_contract_errors"
|
||||
:key="error.transition_id ?? `${error.route_code}-${error.adapter_error_code}`"
|
||||
class="diagnostic-item"
|
||||
>
|
||||
<strong>{{ error.adapter_error_code ?? t('resultType.ADAPTER_CONTRACT_ERROR') }}</strong>
|
||||
<p>{{ error.adapter_error_message ?? '-' }}</p>
|
||||
<small>{{ formatTransitionSummary(error) }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import ReservationV4SourceMessageCard from '@/components/reservation/ReservationV4SourceMessageCard.vue'
|
||||
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
|
||||
import { ApiError } from '@/services/httpClient'
|
||||
import {
|
||||
confirmReservationV4OrderTaskCard,
|
||||
fetchReservationV4OrderTaskDetail,
|
||||
resolveReservationV4OrderTaskCardReview,
|
||||
} from '@/services/reservationService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type {
|
||||
ReservationAiTransitionDisplayResult,
|
||||
ReservationRecord,
|
||||
ReservationV4OrderTaskDetailResult,
|
||||
ReservationV4TaskCardResult,
|
||||
} from '@/types/reservation'
|
||||
import {
|
||||
formatReservationReadonlyReason,
|
||||
formatReservationResultType,
|
||||
formatReservationRouteCode,
|
||||
formatReservationTaskCard,
|
||||
} from '@/utils/reservationDisplay'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
import {
|
||||
buildReservationV4ConfirmedPayload,
|
||||
buildReservationV4InitialFieldValues,
|
||||
buildReservationV4ReviewOverrides,
|
||||
isReservationV4ConfirmWritableField,
|
||||
isReservationV4ReviewWritableField,
|
||||
isReservationV4UnsafeDisplayKey,
|
||||
mapReservationV4BackendDetailsToFields,
|
||||
reservationV4FieldKey,
|
||||
stringifyReservationV4SafeDisplayValue,
|
||||
validateReservationV4Fields,
|
||||
} from '@/utils/reservationV4FieldRules'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const cardSuccessMessages = ref<Record<string, string>>({})
|
||||
const detail = ref<ReservationV4OrderTaskDetailResult | null>(null)
|
||||
const cardValues = ref<Record<string, ReservationRecord>>({})
|
||||
const cardFieldErrors = ref<Record<string, Record<string, string>>>({})
|
||||
const cardActionErrors = ref<Record<string, string[]>>({})
|
||||
const reviewForms = ref<Record<string, { confirmed_order_id: string; reason: string }>>({})
|
||||
const submittingCardId = ref<string | null>(null)
|
||||
let requestSequence = 0
|
||||
|
||||
const orderTaskId = computed(() => String(route.params.orderTaskId ?? ''))
|
||||
const displayOrderReference = computed(() => {
|
||||
const orderTask = detail.value?.order_task
|
||||
return orderTask?.display_order_key ?? orderTask?.order_ref ?? orderTask?.order_id ?? '-'
|
||||
})
|
||||
const targetLocator = computed(() => {
|
||||
const orderTask = detail.value?.order_task
|
||||
if (!orderTask) {
|
||||
return '-'
|
||||
}
|
||||
return [orderTask.target_locator_type, orderTask.target_locator_value].filter(Boolean).join(': ') || '-'
|
||||
})
|
||||
const hasConfirmPermission = computed(() => authStore.hasPermission('RESERVATION_TASK_CONFIRM'))
|
||||
const hasReviewPermission = computed(() => authStore.hasPermission('RESERVATION_MANUAL_REVIEW_RESOLVE'))
|
||||
|
||||
watch(
|
||||
[orderTaskId, () => authStore.selectedHotelId],
|
||||
() => {
|
||||
void loadDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadDetail(): Promise<void> {
|
||||
if (!orderTaskId.value) {
|
||||
return
|
||||
}
|
||||
const currentRequest = ++requestSequence
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
cardSuccessMessages.value = {}
|
||||
try {
|
||||
const result = await fetchReservationV4OrderTaskDetail(orderTaskId.value)
|
||||
if (currentRequest !== requestSequence) {
|
||||
return
|
||||
}
|
||||
applyDetail(result)
|
||||
} catch (error) {
|
||||
if (currentRequest !== requestSequence) {
|
||||
return
|
||||
}
|
||||
detail.value = null
|
||||
errorMessage.value = formatApiError(error, t('taskV4.error.loadFailed')).message
|
||||
} finally {
|
||||
if (currentRequest === requestSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDetail(result: ReservationV4OrderTaskDetailResult): void {
|
||||
detail.value = result
|
||||
const cards = [
|
||||
result.source_message_card,
|
||||
result.basic_information_card,
|
||||
...result.business_cards,
|
||||
].filter((card): card is ReservationV4TaskCardResult => Boolean(card))
|
||||
cardValues.value = cards.reduce<Record<string, ReservationRecord>>((values, card) => {
|
||||
values[card.card_id] = buildReservationV4InitialFieldValues(card.fields)
|
||||
return values
|
||||
}, {})
|
||||
cardFieldErrors.value = {}
|
||||
cardActionErrors.value = {}
|
||||
cardSuccessMessages.value = {}
|
||||
reviewForms.value = cards.reduce<Record<string, { confirmed_order_id: string; reason: string }>>((forms, card) => {
|
||||
forms[card.card_id] = {
|
||||
confirmed_order_id: result.order_task.order_id ?? '',
|
||||
reason: '',
|
||||
}
|
||||
return forms
|
||||
}, {})
|
||||
}
|
||||
|
||||
function setCardValues(card: ReservationV4TaskCardResult, values: ReservationRecord): void {
|
||||
cardValues.value = {
|
||||
...cardValues.value,
|
||||
[card.card_id]: values,
|
||||
}
|
||||
}
|
||||
|
||||
function canConfirmCard(card: ReservationV4TaskCardResult): boolean {
|
||||
return hasConfirmPermission.value &&
|
||||
card.card_status === 'PENDING_CONFIRM' &&
|
||||
card.availability.confirmable &&
|
||||
submittingCardId.value !== card.card_id
|
||||
}
|
||||
|
||||
function canReviewCard(card: ReservationV4TaskCardResult): boolean {
|
||||
return hasReviewPermission.value &&
|
||||
card.card_status === 'REVIEW_REQUIRED' &&
|
||||
card.availability.reviewable &&
|
||||
submittingCardId.value !== card.card_id
|
||||
}
|
||||
|
||||
async function submitConfirm(card: ReservationV4TaskCardResult): Promise<void> {
|
||||
const writableFields = card.fields.filter(isReservationV4ConfirmWritableField)
|
||||
const localErrors = validateReservationV4Fields(writableFields, cardValues.value[card.card_id] ?? {})
|
||||
cardFieldErrors.value = {
|
||||
...cardFieldErrors.value,
|
||||
[card.card_id]: localErrors,
|
||||
}
|
||||
if (Object.keys(localErrors).length) {
|
||||
cardActionErrors.value = {
|
||||
...cardActionErrors.value,
|
||||
[card.card_id]: [t('taskV4.validationFailed')],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
submittingCardId.value = card.card_id
|
||||
cardActionErrors.value = {
|
||||
...cardActionErrors.value,
|
||||
[card.card_id]: [],
|
||||
}
|
||||
cardSuccessMessages.value = {
|
||||
...cardSuccessMessages.value,
|
||||
[card.card_id]: '',
|
||||
}
|
||||
try {
|
||||
const result = await confirmReservationV4OrderTaskCard(orderTaskId.value, card.card_id, {
|
||||
version: card.version,
|
||||
confirmed_payload: buildReservationV4ConfirmedPayload(card.fields, cardValues.value[card.card_id] ?? {}),
|
||||
})
|
||||
applyDetail(result)
|
||||
cardSuccessMessages.value = {
|
||||
...cardSuccessMessages.value,
|
||||
[card.card_id]: t('taskV4.cardConfirmed'),
|
||||
}
|
||||
await nextTick()
|
||||
} catch (error) {
|
||||
applyCardActionError(card, error, t('taskV4.error.actionFailed'))
|
||||
} finally {
|
||||
submittingCardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview(card: ReservationV4TaskCardResult): Promise<void> {
|
||||
const writableFields = card.fields.filter(isReservationV4ReviewWritableField)
|
||||
const localErrors = validateReservationV4Fields(writableFields, cardValues.value[card.card_id] ?? {})
|
||||
cardFieldErrors.value = {
|
||||
...cardFieldErrors.value,
|
||||
[card.card_id]: localErrors,
|
||||
}
|
||||
if (Object.keys(localErrors).length) {
|
||||
cardActionErrors.value = {
|
||||
...cardActionErrors.value,
|
||||
[card.card_id]: [t('taskV4.validationFailed')],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const form = reviewForms.value[card.card_id] ?? { confirmed_order_id: '', reason: '' }
|
||||
if (requiresConfirmedOrderId() && !form.confirmed_order_id.trim()) {
|
||||
cardActionErrors.value = {
|
||||
...cardActionErrors.value,
|
||||
[card.card_id]: [t('taskV4.confirmedOrderRequired')],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
submittingCardId.value = card.card_id
|
||||
cardActionErrors.value = {
|
||||
...cardActionErrors.value,
|
||||
[card.card_id]: [],
|
||||
}
|
||||
cardSuccessMessages.value = {
|
||||
...cardSuccessMessages.value,
|
||||
[card.card_id]: '',
|
||||
}
|
||||
try {
|
||||
const result = await resolveReservationV4OrderTaskCardReview(orderTaskId.value, card.card_id, {
|
||||
version: card.version,
|
||||
confirmed_order_id: form.confirmed_order_id.trim() || undefined,
|
||||
reason: form.reason.trim() || undefined,
|
||||
field_overrides: buildReservationV4ReviewOverrides(card.fields, cardValues.value[card.card_id] ?? {}),
|
||||
})
|
||||
applyDetail(result)
|
||||
cardSuccessMessages.value = {
|
||||
...cardSuccessMessages.value,
|
||||
[card.card_id]: t('taskV4.reviewResolved'),
|
||||
}
|
||||
await nextTick()
|
||||
} catch (error) {
|
||||
applyCardActionError(card, error, t('taskV4.error.actionFailed'))
|
||||
} finally {
|
||||
submittingCardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function requiresConfirmedOrderId(): boolean {
|
||||
const orderTask = detail.value?.order_task
|
||||
if (!orderTask) {
|
||||
return false
|
||||
}
|
||||
return !orderTask.order_id || orderTask.target_resolution_status !== 'RESOLVED'
|
||||
}
|
||||
|
||||
function editableFieldKeys(card: ReservationV4TaskCardResult): string[] {
|
||||
const writableFields = card.card_status === 'REVIEW_REQUIRED'
|
||||
? card.fields.filter(isReservationV4ReviewWritableField)
|
||||
: card.fields.filter(isReservationV4ConfirmWritableField)
|
||||
return writableFields.map(reservationV4FieldKey)
|
||||
}
|
||||
|
||||
function applyCardActionError(card: ReservationV4TaskCardResult, error: unknown, fallback: string): void {
|
||||
const formatted = formatApiError(error, fallback)
|
||||
const mappedErrors = mapReservationV4BackendDetailsToFields(formatted.details, card.fields)
|
||||
cardFieldErrors.value = {
|
||||
...cardFieldErrors.value,
|
||||
[card.card_id]: mappedErrors.fieldErrors,
|
||||
}
|
||||
cardActionErrors.value = {
|
||||
...cardActionErrors.value,
|
||||
[card.card_id]: [formatted.message, ...mappedErrors.globalErrors].filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
function cardTitle(card: ReservationV4TaskCardResult): string {
|
||||
return formatReservationTaskCard(t, card.card_type)
|
||||
}
|
||||
|
||||
function availabilityReason(card: ReservationV4TaskCardResult): string {
|
||||
return card.availability.readonly_reason_message ??
|
||||
(card.availability.readonly_reason_code
|
||||
? formatReservationReadonlyReason(t, card.availability.readonly_reason_code)
|
||||
: card.availability.blocked_reason ?? '')
|
||||
}
|
||||
|
||||
function formatTransitionSummary(error: ReservationAiTransitionDisplayResult): string {
|
||||
return [
|
||||
error.route_code ? formatReservationRouteCode(t, error.route_code) : '',
|
||||
error.result_type ? formatReservationResultType(t, error.result_type) : '',
|
||||
error.transition_id ? `#${error.transition_id}` : '',
|
||||
].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function safePayloadRows(payload: ReservationRecord | null): Array<{ key: string; value: string }> {
|
||||
if (!payload) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(payload)
|
||||
.filter(([key]) => !isUnsafePayloadKey(key))
|
||||
.slice(0, 12)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: stringifySafeValue(value),
|
||||
}))
|
||||
}
|
||||
|
||||
function isUnsafePayloadKey(key: string): boolean {
|
||||
return isReservationV4UnsafeDisplayKey(key)
|
||||
}
|
||||
|
||||
function stringifySafeValue(value: unknown): string {
|
||||
return stringifyReservationV4SafeDisplayValue(
|
||||
value,
|
||||
t('taskV4.field.empty'),
|
||||
t('taskV4.field.hidden'),
|
||||
)
|
||||
}
|
||||
|
||||
function formatApiError(error: unknown, fallback: string): {
|
||||
message: string
|
||||
details: unknown
|
||||
} {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return {
|
||||
message: error instanceof Error ? error.message : fallback,
|
||||
details: [],
|
||||
}
|
||||
}
|
||||
const payload = error.details
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const record = payload as ReservationRecord
|
||||
return {
|
||||
message: typeof record.message === 'string' ? record.message : fallback,
|
||||
details: record.details ?? [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: fallback,
|
||||
details: payload,
|
||||
}
|
||||
}
|
||||
|
||||
const TaskCardSection = defineComponent({
|
||||
name: 'TaskCardSection',
|
||||
props: {
|
||||
card: {
|
||||
type: Object as () => ReservationV4TaskCardResult,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('section', { class: 'th-section task-card-section' }, [
|
||||
h('div', { class: 'th-section-header' }, [
|
||||
h('div', [
|
||||
h('h2', { class: 'th-section-title' }, props.title),
|
||||
h('p', { class: 'th-muted' }, `#${props.card.card_id}`),
|
||||
]),
|
||||
h(ReservationStatusBadge, { status: props.card.card_status }),
|
||||
]),
|
||||
h('div', { class: 'card-meta-grid' }, [
|
||||
h('div', [h('span', t('task.reviewStatus')), h('strong', props.card.review_status ?? '-')]),
|
||||
h('div', [h('span', t('taskV4.readonlyReason')), h('strong', availabilityReason(props.card) || '-')]),
|
||||
h('div', [h('span', t('taskV4.blockedReason')), h('strong', props.card.availability.blocked_reason ?? '-')]),
|
||||
h('div', [h('span', t('taskV4.orderTaskId')), h('strong', detail.value?.order_task.order_task_id ?? '-')]),
|
||||
]),
|
||||
h(ReservationV4TaskCardFieldRenderer, {
|
||||
fields: props.card.fields,
|
||||
modelValue: cardValues.value[props.card.card_id] ?? {},
|
||||
readOnly: !props.card.availability.editable ||
|
||||
props.card.availability.read_only ||
|
||||
submittingCardId.value === props.card.card_id,
|
||||
editableKeys: editableFieldKeys(props.card),
|
||||
validationErrors: cardFieldErrors.value[props.card.card_id] ?? {},
|
||||
'onUpdate:modelValue': (values: ReservationRecord) => setCardValues(props.card, values),
|
||||
}),
|
||||
safePayloadRows(props.card.display_payload).length
|
||||
? h('details', { class: 'safe-payload' }, [
|
||||
h('summary', t('taskV4.displayPayload')),
|
||||
h('dl', safePayloadRows(props.card.display_payload).flatMap((row) => [
|
||||
h('dt', row.key),
|
||||
h('dd', row.value),
|
||||
])),
|
||||
])
|
||||
: null,
|
||||
(cardActionErrors.value[props.card.card_id]?.length ?? 0) > 0
|
||||
? h('div', { class: 'action-message action-message--error' },
|
||||
(cardActionErrors.value[props.card.card_id] ?? []).map((message) => h('p', message)),
|
||||
)
|
||||
: null,
|
||||
cardSuccessMessages.value[props.card.card_id]
|
||||
? h('p', { class: 'action-message action-message--success' }, cardSuccessMessages.value[props.card.card_id])
|
||||
: null,
|
||||
props.card.card_status === 'REVIEW_REQUIRED'
|
||||
? h('div', { class: 'review-box' }, [
|
||||
h('label', [
|
||||
h('span', t('taskV4.confirmedOrderId')),
|
||||
h('input', {
|
||||
value: reviewForms.value[props.card.card_id]?.confirmed_order_id ?? '',
|
||||
placeholder: t('taskV4.confirmedOrderIdPlaceholder'),
|
||||
onInput: (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
reviewForms.value[props.card.card_id] = {
|
||||
...(reviewForms.value[props.card.card_id] ?? { reason: '' }),
|
||||
confirmed_order_id: target.value,
|
||||
}
|
||||
},
|
||||
}),
|
||||
]),
|
||||
h('label', [
|
||||
h('span', t('taskV4.reviewReason')),
|
||||
h('textarea', {
|
||||
value: reviewForms.value[props.card.card_id]?.reason ?? '',
|
||||
placeholder: t('taskV4.reviewReasonPlaceholder'),
|
||||
rows: 3,
|
||||
onInput: (event: Event) => {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
reviewForms.value[props.card.card_id] = {
|
||||
...(reviewForms.value[props.card.card_id] ?? { confirmed_order_id: '' }),
|
||||
reason: target.value,
|
||||
}
|
||||
},
|
||||
}),
|
||||
]),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
class: 'primary-button',
|
||||
disabled: !canReviewCard(props.card),
|
||||
onClick: () => void submitReview(props.card),
|
||||
}, submittingCardId.value === props.card.card_id ? t('taskV4.resolvingReview') : t('taskV4.resolveReview')),
|
||||
])
|
||||
: h('button', {
|
||||
type: 'button',
|
||||
class: 'primary-button',
|
||||
disabled: !canConfirmCard(props.card),
|
||||
onClick: () => void submitConfirm(props.card),
|
||||
}, submittingCardId.value === props.card.card_id ? t('taskV4.confirmingCard') : t('taskV4.confirmCard')),
|
||||
detail.value?.bound_order?.order_id
|
||||
? h(RouterLink, {
|
||||
class: 'card-order-link',
|
||||
to: `/reservation/orders/${detail.value.bound_order.order_id}`,
|
||||
}, () => t('task.viewOrder'))
|
||||
: null,
|
||||
])
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v4-task-detail-view {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-heading h1,
|
||||
.page-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
margin-top: 8px;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 13px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-state--error {
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.summary-section,
|
||||
.task-card-section,
|
||||
.diagnostics-section,
|
||||
.card-stack {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.card-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.summary-grid div,
|
||||
.card-meta-grid div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.card-meta-grid span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summary-grid strong,
|
||||
.card-meta-grid strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.summary-grid small {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card-stack__header {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-card-section :deep(.v4-field-renderer) {
|
||||
padding: 0 20px 18px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
min-height: 38px;
|
||||
border-radius: var(--th-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
justify-self: start;
|
||||
margin: 0 20px 18px;
|
||||
border: 0;
|
||||
background: var(--th-color-blue-600);
|
||||
color: var(--th-color-white);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-700);
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.review-box {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
border-top: 1px solid var(--th-color-slate-200);
|
||||
padding: 16px 20px 18px;
|
||||
}
|
||||
|
||||
.review-box label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.review-box span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.review-box input,
|
||||
.review-box textarea {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
color: var(--th-color-slate-900);
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.review-box .primary-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-message {
|
||||
margin: 0 20px 18px;
|
||||
border-radius: var(--th-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.action-message p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-message p + p {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.action-message--error {
|
||||
background: var(--th-color-danger-bg);
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.action-message--success {
|
||||
background: var(--th-color-success-bg);
|
||||
color: var(--th-color-success);
|
||||
}
|
||||
|
||||
.safe-payload {
|
||||
margin: 0 20px 18px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.safe-payload summary {
|
||||
color: var(--th-color-slate-700);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.safe-payload dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 220px) minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: 0 20px 18px;
|
||||
color: var(--th-color-blue-600);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.diagnostic-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.diagnostic-item {
|
||||
border: 1px solid var(--th-color-danger-bg);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-danger-bg);
|
||||
color: var(--th-color-danger);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.diagnostic-item strong,
|
||||
.diagnostic-item p,
|
||||
.diagnostic-item small {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.diagnostic-item p {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.diagnostic-item small {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.summary-grid,
|
||||
.card-meta-grid,
|
||||
.review-box {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="th-page source-notification-view">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>{{ t('sourceNotification.title') }}</h1>
|
||||
<p>{{ t('sourceNotification.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="loading"
|
||||
@click="loadDetail"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="errorMessage"
|
||||
class="empty-state empty-state--error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="th-section summary-section">
|
||||
<div class="th-section-header">
|
||||
<div>
|
||||
<h2 class="th-section-title">
|
||||
{{ t('sourceNotification.summary') }}
|
||||
</h2>
|
||||
<p class="th-muted">
|
||||
#{{ detail.notification.notification_id }}
|
||||
</p>
|
||||
</div>
|
||||
<ReservationStatusBadge :status="detail.notification.notification_status" />
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div>
|
||||
<span>{{ t('sourceNotification.routeCode') }}</span>
|
||||
<strong>{{ formatReservationRouteCode(t, detail.notification.route_code) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('sourceNotification.status') }}</span>
|
||||
<strong>{{ t(`status.${detail.notification.notification_status}`, detail.notification.notification_status) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('task.sourceReceivedAt') }}</span>
|
||||
<strong>{{ formatReservationDateTime(detail.notification.source_received_at) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('taskV4.readonlyReason') }}</span>
|
||||
<strong>{{ availabilityReason }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ReservationV4SourceMessageCard
|
||||
:title="t('sourceNotification.sourceMessage')"
|
||||
:source-message-summary="detail.conversation_summary"
|
||||
:source-card="detail.source_message_card"
|
||||
/>
|
||||
|
||||
<section class="th-section ack-section">
|
||||
<div class="th-section-header">
|
||||
<div>
|
||||
<h2 class="th-section-title">
|
||||
{{ t('sourceNotification.ack') }}
|
||||
</h2>
|
||||
<p class="th-muted">
|
||||
{{ t('sourceNotification.noBusinessEntry') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>{{ t('sourceNotification.ackReason') }}</span>
|
||||
<textarea
|
||||
v-model="ackReason"
|
||||
rows="3"
|
||||
:placeholder="t('sourceNotification.ackReasonPlaceholder')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="actionError"
|
||||
class="action-message action-message--error"
|
||||
>
|
||||
{{ actionError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="successMessage"
|
||||
class="action-message action-message--success"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="primary-button"
|
||||
:disabled="!canAck"
|
||||
@click="ackNotification"
|
||||
>
|
||||
{{ submitting ? t('sourceNotification.acking') : t('sourceNotification.ack') }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import ReservationV4SourceMessageCard from '@/components/reservation/ReservationV4SourceMessageCard.vue'
|
||||
import { ApiError } from '@/services/httpClient'
|
||||
import {
|
||||
ackReservationV4SourceNotification,
|
||||
fetchReservationV4SourceNotificationDetail,
|
||||
} from '@/services/reservationService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { ReservationRecord, ReservationV4SourceNotificationDetailResult } from '@/types/reservation'
|
||||
import {
|
||||
formatReservationReadonlyReason,
|
||||
formatReservationRouteCode,
|
||||
} from '@/utils/reservationDisplay'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { t } = useI18n()
|
||||
const detail = ref<ReservationV4SourceNotificationDetailResult | null>(null)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const actionError = ref('')
|
||||
const successMessage = ref('')
|
||||
const ackReason = ref('')
|
||||
let requestSequence = 0
|
||||
|
||||
const notificationId = computed(() => String(route.params.notificationId ?? ''))
|
||||
const canAck = computed(() =>
|
||||
Boolean(detail.value?.availability.ackable) &&
|
||||
authStore.hasPermission('RESERVATION_TASK_CONFIRM') &&
|
||||
!submitting.value,
|
||||
)
|
||||
const availabilityReason = computed(() => {
|
||||
const availability = detail.value?.availability
|
||||
if (!availability) {
|
||||
return '-'
|
||||
}
|
||||
return availability.readonly_reason_message ??
|
||||
(availability.readonly_reason_code
|
||||
? formatReservationReadonlyReason(t, availability.readonly_reason_code)
|
||||
: availability.blocked_reason ?? '-')
|
||||
})
|
||||
|
||||
watch(
|
||||
[notificationId, () => authStore.selectedHotelId],
|
||||
() => {
|
||||
void loadDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadDetail(): Promise<void> {
|
||||
if (!notificationId.value) {
|
||||
return
|
||||
}
|
||||
const currentRequest = ++requestSequence
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
actionError.value = ''
|
||||
try {
|
||||
const result = await fetchReservationV4SourceNotificationDetail(notificationId.value)
|
||||
if (currentRequest !== requestSequence) {
|
||||
return
|
||||
}
|
||||
detail.value = result
|
||||
} catch (error) {
|
||||
if (currentRequest !== requestSequence) {
|
||||
return
|
||||
}
|
||||
detail.value = null
|
||||
errorMessage.value = formatApiError(error, t('sourceNotification.error.loadFailed'))
|
||||
} finally {
|
||||
if (currentRequest === requestSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ackNotification(): Promise<void> {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
actionError.value = ''
|
||||
successMessage.value = ''
|
||||
try {
|
||||
detail.value = await ackReservationV4SourceNotification(notificationId.value, {
|
||||
version: detail.value.notification.version,
|
||||
reason: ackReason.value.trim() || undefined,
|
||||
})
|
||||
successMessage.value = t('sourceNotification.acked')
|
||||
ackReason.value = ''
|
||||
} catch (error) {
|
||||
actionError.value = formatApiError(error, t('sourceNotification.error.ackFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatApiError(error: unknown, fallback: string): string {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
const payload = error.details
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const record = payload as ReservationRecord
|
||||
return typeof record.message === 'string' ? record.message : fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.source-notification-view {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-heading h1,
|
||||
.page-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
margin-top: 8px;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 13px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-state--error {
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.summary-section,
|
||||
.ack-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.summary-grid div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.ack-section label span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ack-section label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 0 20px 18px;
|
||||
}
|
||||
|
||||
.ack-section textarea {
|
||||
min-height: 88px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
color: var(--th-color-slate-900);
|
||||
font: inherit;
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
min-height: 38px;
|
||||
border-radius: var(--th-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
margin: 0 20px 18px;
|
||||
border: 0;
|
||||
background: var(--th-color-blue-600);
|
||||
color: var(--th-color-white);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-700);
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.action-message {
|
||||
margin: 0 20px 18px;
|
||||
border-radius: var(--th-radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.action-message--error {
|
||||
background: var(--th-color-danger-bg);
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.action-message--success {
|
||||
background: var(--th-color-success-bg);
|
||||
color: var(--th-color-success);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user