Files
Wyndham-RSVN/client/src/views/reservation/ReservationV4SourceNotificationDetailView.vue
T

377 lines
9.2 KiB
Vue

<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>