优化V4订单任务卡片样式
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<section class="th-section task-card-section">
|
||||
<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>
|
||||
|
||||
<ReservationV4TaskCardFieldRenderer
|
||||
class="task-card-section__fields"
|
||||
:fields="card.fields"
|
||||
:model-value="modelValue"
|
||||
:read-only="readOnly"
|
||||
:editable-keys="editableKeys"
|
||||
:validation-errors="validationErrors"
|
||||
:hotel-id="hotelId"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<details
|
||||
v-if="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
|
||||
: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.resolvingReview') : t('taskV4.resolveReview') }}
|
||||
</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 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
|
||||
actionErrors?: string[]
|
||||
successMessage?: string
|
||||
reviewForm?: ReviewForm
|
||||
submitting: boolean
|
||||
canConfirm: boolean
|
||||
canReview: boolean
|
||||
boundOrderId?: string | null
|
||||
}>(), {
|
||||
validationErrors: () => ({}),
|
||||
hotelId: undefined,
|
||||
orderTaskId: 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 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,
|
||||
})
|
||||
}
|
||||
</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>
|
||||
@@ -96,6 +96,28 @@ describe('reservation V4 pages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps V4 task card layout classes for styled card metadata and payload summary', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
detail.business_cards[0]!.display_payload = {
|
||||
booking_scenario: 'STANDARD',
|
||||
relevant_message_excerpt: 'Please keep this visible.',
|
||||
}
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.task-card-section')).toHaveLength(2)
|
||||
expect(wrapper.find('.task-card-section__header').exists()).toBe(true)
|
||||
expect(wrapper.find('.card-meta-grid').exists()).toBe(true)
|
||||
expect(wrapper.find('.card-meta-item').text()).toContain(zhCN.task.reviewStatus)
|
||||
expect(wrapper.find('details.safe-payload > summary').text()).toContain(zhCN.taskV4.displayPayload)
|
||||
expect(wrapper.find('.card-actions .primary-button').text()).toContain(zhCN.taskV4.confirmCard)
|
||||
})
|
||||
|
||||
it('does not render direct attachment URLs from V4 source message payload', async () => {
|
||||
const detail = createOrderTaskDetail({
|
||||
sourceDisplayPayload: {
|
||||
|
||||
@@ -88,6 +88,23 @@
|
||||
v-if="detail.basic_information_card"
|
||||
:card="detail.basic_information_card"
|
||||
:title="t('taskV4.basicInformationCard')"
|
||||
:model-value="cardValues[detail.basic_information_card.card_id] ?? {}"
|
||||
:read-only="isCardReadOnly(detail.basic_information_card)"
|
||||
:editable-keys="editableFieldKeys(detail.basic_information_card)"
|
||||
:validation-errors="cardFieldErrors[detail.basic_information_card.card_id] ?? {}"
|
||||
:hotel-id="detail.order_task.hotel_id"
|
||||
:order-task-id="detail.order_task.order_task_id"
|
||||
:action-errors="cardActionErrors[detail.basic_information_card.card_id] ?? []"
|
||||
:success-message="cardSuccessMessages[detail.basic_information_card.card_id] ?? ''"
|
||||
:review-form="reviewForms[detail.basic_information_card.card_id]"
|
||||
:submitting="submittingCardId === detail.basic_information_card.card_id"
|
||||
:can-confirm="canConfirmCard(detail.basic_information_card)"
|
||||
:can-review="canReviewCard(detail.basic_information_card)"
|
||||
:bound-order-id="detail.bound_order?.order_id"
|
||||
@update:model-value="setCardValues(detail.basic_information_card, $event)"
|
||||
@update:review-form="setReviewForm(detail.basic_information_card.card_id, $event)"
|
||||
@confirm="submitConfirm(detail.basic_information_card)"
|
||||
@resolve-review="submitReview(detail.basic_information_card)"
|
||||
/>
|
||||
|
||||
<section class="card-stack">
|
||||
@@ -113,6 +130,23 @@
|
||||
:key="businessCard.card_id"
|
||||
:card="businessCard"
|
||||
:title="cardTitle(businessCard)"
|
||||
:model-value="cardValues[businessCard.card_id] ?? {}"
|
||||
:read-only="isCardReadOnly(businessCard)"
|
||||
:editable-keys="editableFieldKeys(businessCard)"
|
||||
:validation-errors="cardFieldErrors[businessCard.card_id] ?? {}"
|
||||
:hotel-id="detail.order_task.hotel_id"
|
||||
:order-task-id="detail.order_task.order_task_id"
|
||||
:action-errors="cardActionErrors[businessCard.card_id] ?? []"
|
||||
:success-message="cardSuccessMessages[businessCard.card_id] ?? ''"
|
||||
:review-form="reviewForms[businessCard.card_id]"
|
||||
:submitting="submittingCardId === businessCard.card_id"
|
||||
:can-confirm="canConfirmCard(businessCard)"
|
||||
:can-review="canReviewCard(businessCard)"
|
||||
:bound-order-id="detail.bound_order?.order_id"
|
||||
@update:model-value="setCardValues(businessCard, $event)"
|
||||
@update:review-form="setReviewForm(businessCard.card_id, $event)"
|
||||
@confirm="submitConfirm(businessCard)"
|
||||
@resolve-review="submitReview(businessCard)"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
@@ -148,13 +182,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import { computed, nextTick, 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 ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
|
||||
import TaskCardSection from '@/components/reservation/ReservationV4TaskCardSection.vue'
|
||||
import { ApiError } from '@/services/httpClient'
|
||||
import {
|
||||
confirmReservationV4OrderTaskCard,
|
||||
@@ -169,7 +203,6 @@ import type {
|
||||
ReservationV4TaskCardResult,
|
||||
} from '@/types/reservation'
|
||||
import {
|
||||
formatReservationReadonlyReason,
|
||||
formatReservationResultType,
|
||||
formatReservationRouteCode,
|
||||
formatReservationTaskCard,
|
||||
@@ -181,10 +214,8 @@ import {
|
||||
buildReservationV4ReviewOverrides,
|
||||
isReservationV4ConfirmWritableField,
|
||||
isReservationV4ReviewWritableField,
|
||||
isReservationV4UnsafeDisplayKey,
|
||||
mapReservationV4BackendDetailsToFields,
|
||||
reservationV4FieldKey,
|
||||
stringifyReservationV4SafeDisplayValue,
|
||||
validateReservationV4Fields,
|
||||
} from '@/utils/reservationV4FieldRules'
|
||||
|
||||
@@ -282,6 +313,19 @@ function setCardValues(card: ReservationV4TaskCardResult, values: ReservationRec
|
||||
}
|
||||
}
|
||||
|
||||
function setReviewForm(cardId: string, form: { confirmed_order_id: string; reason: string }): void {
|
||||
reviewForms.value = {
|
||||
...reviewForms.value,
|
||||
[cardId]: form,
|
||||
}
|
||||
}
|
||||
|
||||
function isCardReadOnly(card: ReservationV4TaskCardResult): boolean {
|
||||
return !card.availability.editable ||
|
||||
card.availability.read_only ||
|
||||
submittingCardId.value === card.card_id
|
||||
}
|
||||
|
||||
function canConfirmCard(card: ReservationV4TaskCardResult): boolean {
|
||||
return hasConfirmPermission.value &&
|
||||
card.card_status === 'PENDING_CONFIRM' &&
|
||||
@@ -423,13 +467,6 @@ 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) : '',
|
||||
@@ -438,31 +475,6 @@ function formatTransitionSummary(error: ReservationAiTransitionDisplayResult): s
|
||||
].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
|
||||
@@ -487,114 +499,6 @@ function formatApiError(error: unknown, fallback: string): {
|
||||
}
|
||||
}
|
||||
|
||||
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] ?? {},
|
||||
hotelId: detail.value?.order_task.hotel_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>
|
||||
@@ -639,35 +543,30 @@ const TaskCardSection = defineComponent({
|
||||
}
|
||||
|
||||
.summary-section,
|
||||
.task-card-section,
|
||||
.diagnostics-section,
|
||||
.card-stack {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.card-meta-grid {
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.summary-grid div,
|
||||
.card-meta-grid div {
|
||||
.summary-grid div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.card-meta-grid span {
|
||||
.summary-grid span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summary-grid strong,
|
||||
.card-meta-grid strong {
|
||||
.summary-grid strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -688,11 +587,6 @@ const TaskCardSection = defineComponent({
|
||||
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);
|
||||
@@ -702,128 +596,17 @@ const TaskCardSection = defineComponent({
|
||||
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;
|
||||
@@ -854,9 +637,7 @@ const TaskCardSection = defineComponent({
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.summary-grid,
|
||||
.card-meta-grid,
|
||||
.review-box {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user