534 lines
13 KiB
Vue
534 lines
13 KiB
Vue
<template>
|
|
<section
|
|
class="th-section task-card-section"
|
|
:data-testid="isRoomInformationCard ? 'room-information-card' : undefined"
|
|
>
|
|
<div class="task-card-section__header">
|
|
<div class="task-card-section__heading">
|
|
<h2 class="th-section-title">
|
|
{{ title }}
|
|
</h2>
|
|
<p class="task-card-section__id">
|
|
#{{ card.card_id }}
|
|
</p>
|
|
</div>
|
|
<ReservationStatusBadge :status="card.card_status" />
|
|
</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>
|
|
|
|
<ReservationV4RoomInformationCard
|
|
v-if="isRoomInformationCard"
|
|
class="task-card-section__fields"
|
|
:card="card"
|
|
:model-value="modelValue"
|
|
:read-only="readOnly"
|
|
:editable-keys="editableKeys"
|
|
:validation-errors="validationErrors"
|
|
:hotel-id="hotelId"
|
|
@update:model-value="emit('update:modelValue', $event)"
|
|
/>
|
|
<template v-else>
|
|
<ReservationV4PaymentAttachmentPreview
|
|
v-if="isPaymentCard"
|
|
:card="card"
|
|
:source-message-id="sourceMessageId"
|
|
/>
|
|
<ReservationV4TaskCardFieldRenderer
|
|
v-else
|
|
class="task-card-section__fields"
|
|
:fields="visibleFields"
|
|
:model-value="modelValue"
|
|
:read-only="readOnly"
|
|
:editable-keys="editableKeys"
|
|
:validation-errors="validationErrors"
|
|
:hotel-id="hotelId"
|
|
@update:model-value="emit('update:modelValue', $event)"
|
|
/>
|
|
</template>
|
|
|
|
<details
|
|
v-if="!isRoomInformationCard && 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"
|
|
>
|
|
<p
|
|
v-for="message in actionErrors"
|
|
:key="message"
|
|
>
|
|
{{ message }}
|
|
</p>
|
|
</div>
|
|
|
|
<p
|
|
v-if="successMessage"
|
|
class="action-message action-message--success"
|
|
>
|
|
{{ successMessage }}
|
|
</p>
|
|
|
|
<div
|
|
v-if="card.card_status === 'REVIEW_REQUIRED'"
|
|
class="review-box"
|
|
>
|
|
<label>
|
|
<span>{{ t('taskV4.confirmedOrderId') }}</span>
|
|
<input
|
|
name="v4_confirmed_order_id"
|
|
:value="reviewForm.confirmed_order_id"
|
|
:placeholder="t('taskV4.confirmedOrderIdPlaceholder')"
|
|
@input="updateReviewForm('confirmed_order_id', $event)"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('taskV4.reviewReason') }}</span>
|
|
<textarea
|
|
:value="reviewForm.reason"
|
|
:placeholder="t('taskV4.reviewReasonPlaceholder')"
|
|
rows="3"
|
|
@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"
|
|
class="card-order-link"
|
|
:to="`/reservation/orders/${boundOrderId}`"
|
|
>
|
|
{{ t('task.viewOrder') }}
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="card-actions"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="primary-button"
|
|
:disabled="!canConfirm"
|
|
@click="emit('confirm')"
|
|
>
|
|
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
|
|
</button>
|
|
<RouterLink
|
|
v-if="boundOrderId"
|
|
class="card-order-link"
|
|
:to="`/reservation/orders/${boundOrderId}`"
|
|
>
|
|
{{ t('task.viewOrder') }}
|
|
</RouterLink>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { RouterLink } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
|
import ReservationV4PaymentAttachmentPreview from '@/components/reservation/ReservationV4PaymentAttachmentPreview.vue'
|
|
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
|
|
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.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
|
|
reason: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
card: ReservationV4TaskCardResult
|
|
title: string
|
|
modelValue: ReservationRecord
|
|
readOnly: boolean
|
|
editableKeys: string[]
|
|
validationErrors?: Record<string, string>
|
|
hotelId?: string
|
|
orderTaskId?: string | null
|
|
sourceMessageId?: string | null
|
|
actionErrors?: string[]
|
|
successMessage?: string
|
|
reviewForm?: ReviewForm
|
|
submitting: boolean
|
|
canConfirm: boolean
|
|
canReview: boolean
|
|
boundOrderId?: string | null
|
|
}>(), {
|
|
validationErrors: () => ({}),
|
|
hotelId: undefined,
|
|
orderTaskId: null,
|
|
sourceMessageId: null,
|
|
actionErrors: () => [],
|
|
successMessage: '',
|
|
reviewForm: () => ({
|
|
confirmed_order_id: '',
|
|
reason: '',
|
|
}),
|
|
boundOrderId: null,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: ReservationRecord]
|
|
'update:reviewForm': [value: ReviewForm]
|
|
confirm: []
|
|
resolveReview: []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
|
|
const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT')
|
|
const visibleFields = computed(() =>
|
|
isPaymentCard.value
|
|
? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field))
|
|
: props.card.fields,
|
|
)
|
|
|
|
const availabilityReason = computed(() => {
|
|
return props.card.availability.readonly_reason_message ??
|
|
(props.card.availability.readonly_reason_code
|
|
? 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'),
|
|
),
|
|
}))
|
|
})
|
|
|
|
function updateReviewForm(field: keyof ReviewForm, event: Event): void {
|
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
|
emit('update:reviewForm', {
|
|
...props.reviewForm,
|
|
[field]: target.value,
|
|
})
|
|
}
|
|
|
|
function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
|
|
const normalizedKey = `${field.field_pointer || ''} ${field.field_path || ''}`.toLowerCase()
|
|
return normalizedKey.includes('attachment_ids')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.task-card-section {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.task-card-section__header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
border-bottom: 1px solid var(--th-color-slate-200);
|
|
padding: 20px;
|
|
}
|
|
|
|
.task-card-section__heading {
|
|
display: grid;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.task-card-section__id {
|
|
margin: 0;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.card-meta-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
padding: 16px 20px 18px;
|
|
}
|
|
|
|
.card-meta-item {
|
|
display: grid;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-slate-50);
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.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);
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.task-card-section__fields {
|
|
padding: 0 20px 18px;
|
|
}
|
|
|
|
.primary-button {
|
|
min-height: 38px;
|
|
border: 0;
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-blue-600);
|
|
color: var(--th-color-white);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
padding: 8px 14px;
|
|
transition: background-color 160ms ease, transform 160ms ease, opacity 160ms ease;
|
|
}
|
|
|
|
.primary-button:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--th-color-blue-600) 86%, var(--th-color-navy-950));
|
|
}
|
|
|
|
.primary-button:active:not(:disabled) {
|
|
transform: translateY(1px);
|
|
}
|
|
|
|
.primary-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.card-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 0 20px 20px;
|
|
}
|
|
|
|
.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);
|
|
background: linear-gradient(180deg, var(--th-color-white), var(--th-color-slate-50));
|
|
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);
|
|
background: var(--th-color-white);
|
|
color: var(--th-color-slate-900);
|
|
font: inherit;
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.review-box input:focus,
|
|
.review-box textarea:focus {
|
|
border-color: var(--th-color-blue-600);
|
|
box-shadow: 0 0 0 3px var(--th-color-info-bg);
|
|
outline: 0;
|
|
}
|
|
|
|
.review-box__actions {
|
|
display: grid;
|
|
gap: 10px;
|
|
justify-items: start;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
color: var(--th-color-blue-600);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.card-order-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.card-meta-grid,
|
|
.review-box {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.card-actions {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|