377 lines
9.2 KiB
Vue
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>
|