接入M002V4订单任务多卡前端

This commit is contained in:
andy committed 2026-07-19 13:48:07 +07:00
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>