接入V4任务卡目录查询
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
v-if="isEditable(field) && isSelectField(field)"
|
||||
class="v4-field__control"
|
||||
:aria-invalid="fieldError(field) ? 'true' : 'false'"
|
||||
:disabled="selectDisabled(field)"
|
||||
:value="fieldValue(field)"
|
||||
@change="updateField(field, $event)"
|
||||
>
|
||||
@@ -76,6 +77,12 @@
|
||||
v-if="field.control_hint"
|
||||
class="v4-field__hint"
|
||||
>{{ field.control_hint }}</small>
|
||||
<small
|
||||
v-for="hint in lookupHints(field)"
|
||||
:key="hint.key"
|
||||
class="v4-field__hint"
|
||||
:class="`v4-field__hint--${hint.tone}`"
|
||||
>{{ hint.message }}</small>
|
||||
<small
|
||||
v-for="error in visibleFieldErrors(field)"
|
||||
:key="error"
|
||||
@@ -86,15 +93,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ReservationRecord, ReservationV4TaskCardFieldResult } from '@/types/reservation'
|
||||
import {
|
||||
fetchReservationV4AccountLookups,
|
||||
fetchReservationV4RateCodeLookups,
|
||||
fetchReservationV4RoomTypeLookups,
|
||||
} from '@/services/reservationService'
|
||||
import type {
|
||||
ReservationRecord,
|
||||
ReservationV4CatalogLookupItem,
|
||||
ReservationV4CatalogLookupResult,
|
||||
ReservationV4TaskCardFieldResult,
|
||||
} from '@/types/reservation'
|
||||
import {
|
||||
isReservationV4EditableField,
|
||||
readV4FieldValue,
|
||||
reservationV4LookupKindForOptionsSource,
|
||||
reservationV4FieldKey,
|
||||
staticReservationV4Options,
|
||||
stringifyReservationV4SafeDisplayValue,
|
||||
type ReservationV4LookupKind,
|
||||
} from '@/utils/reservationV4FieldRules'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -103,6 +122,7 @@ const props = defineProps<{
|
||||
readOnly: boolean
|
||||
validationErrors?: Record<string, string>
|
||||
editableKeys?: string[]
|
||||
hotelId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -110,6 +130,65 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
type FieldOption = {
|
||||
value: string
|
||||
label: string
|
||||
item?: ReservationV4CatalogLookupItem
|
||||
}
|
||||
type LookupHintTone = 'normal' | 'warning' | 'error'
|
||||
type LookupHint = {
|
||||
key: string
|
||||
message: string
|
||||
tone: LookupHintTone
|
||||
}
|
||||
type LookupState = {
|
||||
loading: boolean
|
||||
error: string
|
||||
result: ReservationV4CatalogLookupResult | null
|
||||
}
|
||||
|
||||
const invalidLookupValues = reactive<Record<string, string>>({})
|
||||
const lookupStates = reactive<Record<ReservationV4LookupKind, LookupState>>({
|
||||
ACCOUNT: createLookupState(),
|
||||
ROOM_TYPE: createLookupState(),
|
||||
RATE_CODE: createLookupState(),
|
||||
})
|
||||
const lookupRequestVersions: Record<ReservationV4LookupKind, number> = {
|
||||
ACCOUNT: 0,
|
||||
ROOM_TYPE: 0,
|
||||
RATE_CODE: 0,
|
||||
}
|
||||
const lookupKinds = computed(() => {
|
||||
const kinds = props.fields
|
||||
.filter((field) => isSelectControl(field) && isReservationV4EditableField(field))
|
||||
.map((field) => reservationV4LookupKindForOptionsSource(field.options_source))
|
||||
.filter((kind): kind is ReservationV4LookupKind => Boolean(kind))
|
||||
return [...new Set(kinds)]
|
||||
})
|
||||
|
||||
watch(
|
||||
[lookupKinds, () => props.hotelId],
|
||||
() => {
|
||||
resetLookupStates()
|
||||
lookupKinds.value.forEach((kind) => {
|
||||
void loadLookup(kind)
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.modelValue,
|
||||
() => lookupStates.ACCOUNT.result,
|
||||
() => lookupStates.ROOM_TYPE.result,
|
||||
() => lookupStates.RATE_CODE.result,
|
||||
],
|
||||
() => {
|
||||
clearInvalidLoadedCatalogValues()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
|
||||
return reservationV4FieldKey(field)
|
||||
@@ -123,7 +202,7 @@ function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
}
|
||||
|
||||
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
return normalizeControlType(field.control_type) === 'SELECT' && fieldOptions(field).length > 0
|
||||
return isSelectControl(field) && Boolean(reservationV4LookupKindForOptionsSource(field.options_source))
|
||||
}
|
||||
|
||||
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
@@ -138,8 +217,25 @@ function isTextAreaField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
return normalizeControlType(field.control_type) === 'TEXTAREA'
|
||||
}
|
||||
|
||||
function fieldOptions(field: ReservationV4TaskCardFieldResult): Array<{ value: string; label: string }> {
|
||||
return staticReservationV4Options(field.options_source)
|
||||
function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
|
||||
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
|
||||
const options = kind
|
||||
? (lookupStates[kind].result?.items ?? []).map((item) => ({
|
||||
value: item.code,
|
||||
label: lookupItemLabel(item),
|
||||
item,
|
||||
}))
|
||||
: []
|
||||
return options
|
||||
}
|
||||
|
||||
function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
|
||||
if (!kind) {
|
||||
return false
|
||||
}
|
||||
const state = lookupStates[kind]
|
||||
return state.loading || Boolean(state.error)
|
||||
}
|
||||
|
||||
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
|
||||
@@ -158,12 +254,92 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
|
||||
|
||||
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
|
||||
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
delete invalidLookupValues[reservationV4FieldKey(field)]
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[reservationV4FieldKey(field)]: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
|
||||
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
|
||||
if (!kind) {
|
||||
return []
|
||||
}
|
||||
const state = lookupStates[kind]
|
||||
if (state.loading) {
|
||||
return [{
|
||||
key: `${kind}-loading`,
|
||||
message: t('taskV4.lookup.loading'),
|
||||
tone: 'normal',
|
||||
}]
|
||||
}
|
||||
if (state.error) {
|
||||
return [{
|
||||
key: `${kind}-error`,
|
||||
message: state.error,
|
||||
tone: 'error',
|
||||
}]
|
||||
}
|
||||
const result = state.result
|
||||
if (!result) {
|
||||
return []
|
||||
}
|
||||
|
||||
const hints: LookupHint[] = []
|
||||
const selectedItem = selectedLookupItem(field)
|
||||
if (kind === 'ACCOUNT' && selectedItem && (selectedItem.market_code || selectedItem.source_code)) {
|
||||
hints.push({
|
||||
key: `${kind}-derived`,
|
||||
message: t('taskV4.lookup.accountDerived', {
|
||||
market: selectedItem.market_code ?? '-',
|
||||
source: selectedItem.source_code ?? '-',
|
||||
}),
|
||||
tone: 'normal',
|
||||
})
|
||||
}
|
||||
if (result.stale) {
|
||||
hints.push({
|
||||
key: `${kind}-stale`,
|
||||
message: t('taskV4.lookup.stale'),
|
||||
tone: 'warning',
|
||||
})
|
||||
}
|
||||
result.warnings.forEach((warning, index) => {
|
||||
hints.push({
|
||||
key: `${kind}-warning-${index}`,
|
||||
message: t('taskV4.lookup.warning', { warning }),
|
||||
tone: 'warning',
|
||||
})
|
||||
})
|
||||
if (!result.items.length) {
|
||||
hints.push({
|
||||
key: `${kind}-empty`,
|
||||
message: t('taskV4.lookup.empty'),
|
||||
tone: 'warning',
|
||||
})
|
||||
}
|
||||
if (result.catalog_source || result.catalog_version) {
|
||||
hints.push({
|
||||
key: `${kind}-meta`,
|
||||
message: t('taskV4.lookup.catalogMeta', {
|
||||
source: result.catalog_source ?? '-',
|
||||
version: result.catalog_version ?? '-',
|
||||
}),
|
||||
tone: 'normal',
|
||||
})
|
||||
}
|
||||
const invalidValue = invalidLookupValues[reservationV4FieldKey(field)]
|
||||
if (invalidValue) {
|
||||
hints.push({
|
||||
key: `${kind}-invalid-current`,
|
||||
message: t('taskV4.lookup.currentNotInCatalog', { value: invalidValue }),
|
||||
tone: 'error',
|
||||
})
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
|
||||
return [
|
||||
...(field.validation_errors ?? []),
|
||||
@@ -186,6 +362,122 @@ function stringifyV4Value(value: unknown): string {
|
||||
function normalizeControlType(value: string | null): string {
|
||||
return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
|
||||
}
|
||||
|
||||
function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
return ['SELECT', 'LOOKUP'].includes(normalizeControlType(field.control_type))
|
||||
}
|
||||
|
||||
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
|
||||
if (item.display_name && item.display_name !== item.code) {
|
||||
return `${item.code} - ${item.display_name}`
|
||||
}
|
||||
return item.code
|
||||
}
|
||||
|
||||
function selectedLookupItem(field: ReservationV4TaskCardFieldResult): ReservationV4CatalogLookupItem | null {
|
||||
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
|
||||
const value = String(fieldValue(field) || '')
|
||||
if (!kind || !value) {
|
||||
return null
|
||||
}
|
||||
return lookupStates[kind].result?.items.find((item) => item.code === value) ?? null
|
||||
}
|
||||
|
||||
function createLookupState(): LookupState {
|
||||
return {
|
||||
loading: false,
|
||||
error: '',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
|
||||
function resetLookupStates(): void {
|
||||
const allKinds: ReservationV4LookupKind[] = ['ACCOUNT', 'ROOM_TYPE', 'RATE_CODE']
|
||||
allKinds.forEach((kind) => {
|
||||
lookupRequestVersions[kind] += 1
|
||||
lookupStates[kind].loading = false
|
||||
lookupStates[kind].error = ''
|
||||
lookupStates[kind].result = null
|
||||
})
|
||||
Object.keys(invalidLookupValues).forEach((key) => {
|
||||
delete invalidLookupValues[key]
|
||||
})
|
||||
}
|
||||
|
||||
async function loadLookup(kind: ReservationV4LookupKind): Promise<void> {
|
||||
const state = lookupStates[kind]
|
||||
const requestVersion = ++lookupRequestVersions[kind]
|
||||
state.loading = true
|
||||
state.error = ''
|
||||
try {
|
||||
const result = await lookupFetcher(kind)({
|
||||
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
})
|
||||
if (requestVersion === lookupRequestVersions[kind]) {
|
||||
state.result = {
|
||||
...result,
|
||||
items: result.items ?? [],
|
||||
warnings: result.warnings ?? [],
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (requestVersion === lookupRequestVersions[kind]) {
|
||||
state.error = t('taskV4.lookup.error')
|
||||
}
|
||||
} finally {
|
||||
if (requestVersion === lookupRequestVersions[kind]) {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lookupFetcher(kind: ReservationV4LookupKind) {
|
||||
switch (kind) {
|
||||
case 'ACCOUNT':
|
||||
return fetchReservationV4AccountLookups
|
||||
case 'ROOM_TYPE':
|
||||
return fetchReservationV4RoomTypeLookups
|
||||
case 'RATE_CODE':
|
||||
return fetchReservationV4RateCodeLookups
|
||||
}
|
||||
}
|
||||
|
||||
function clearInvalidLoadedCatalogValues(): void {
|
||||
const nextValues = { ...props.modelValue }
|
||||
let changed = false
|
||||
props.fields.forEach((field) => {
|
||||
if (!isSelectField(field) || !isReservationV4EditableField(field)) {
|
||||
return
|
||||
}
|
||||
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
|
||||
const result = kind ? lookupStates[kind].result : null
|
||||
if (!kind || !result || !isFullyLoadedCatalog(result)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = reservationV4FieldKey(field)
|
||||
const currentValue = String(readV4FieldValue(field, nextValues) || '')
|
||||
if (!currentValue) {
|
||||
return
|
||||
}
|
||||
if (result.items.some((item) => item.code === currentValue)) {
|
||||
delete invalidLookupValues[key]
|
||||
return
|
||||
}
|
||||
invalidLookupValues[key] = currentValue
|
||||
nextValues[key] = ''
|
||||
changed = true
|
||||
})
|
||||
if (changed) {
|
||||
emit('update:modelValue', nextValues)
|
||||
}
|
||||
}
|
||||
|
||||
function isFullyLoadedCatalog(result: ReservationV4CatalogLookupResult): boolean {
|
||||
return result.items.length >= result.page.total
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -255,6 +547,16 @@ function normalizeControlType(value: string | null): string {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.v4-field__hint--warning {
|
||||
color: var(--th-color-warning);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.v4-field__hint--error {
|
||||
color: var(--th-color-danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.v4-field__error {
|
||||
color: var(--th-color-danger);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -406,6 +406,8 @@ export default {
|
||||
order: {
|
||||
summary: 'Order summary',
|
||||
taskQueue: 'Order task queue',
|
||||
v4TaskTimeline: 'V4 order task timeline',
|
||||
v4TaskTimelineEmpty: 'No V4 order tasks.',
|
||||
sourceMessage: 'Source message',
|
||||
businessKeySource: 'Business key source',
|
||||
updatedAt: 'Last updated',
|
||||
@@ -500,6 +502,16 @@ export default {
|
||||
cardConfirmed: 'Card confirmed and detail refreshed.',
|
||||
reviewResolved: 'Review submitted and detail refreshed.',
|
||||
validationFailed: 'Fix current-card field errors first.',
|
||||
lookup: {
|
||||
loading: 'Loading catalog',
|
||||
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',
|
||||
stale: 'This catalog is a stale snapshot. You can still select from it; backend validation remains final.',
|
||||
warning: 'Catalog note: {warning}',
|
||||
error: 'Catalog is temporarily unavailable. Refresh and retry.',
|
||||
catalogMeta: 'Catalog source: {source}, version: {version}',
|
||||
accountDerived: 'Market: {market} / Source: {source} will be shown from the catalog; backend confirmation remains final.',
|
||||
currentNotInCatalog: 'Original value "{value}" is not in the current catalog. Select again.',
|
||||
},
|
||||
sourceMessage: {
|
||||
subject: 'Subject',
|
||||
sender: 'Sender',
|
||||
|
||||
@@ -406,6 +406,8 @@ export default {
|
||||
order: {
|
||||
summary: 'สรุปออเดอร์',
|
||||
taskQueue: 'คิวงานของออเดอร์นี้',
|
||||
v4TaskTimeline: 'ไทม์ไลน์งานออเดอร์ V4',
|
||||
v4TaskTimelineEmpty: 'ยังไม่มีงานออเดอร์ V4',
|
||||
sourceMessage: 'ข้อความต้นทาง',
|
||||
businessKeySource: 'แหล่งที่มาของเลขธุรกิจ',
|
||||
updatedAt: 'อัปเดตล่าสุด',
|
||||
@@ -500,6 +502,16 @@ export default {
|
||||
cardConfirmed: 'ยืนยันการ์ดแล้วและรีเฟรชรายละเอียดแล้ว',
|
||||
reviewResolved: 'ส่งผลตรวจสอบแล้วและรีเฟรชรายละเอียดแล้ว',
|
||||
validationFailed: 'โปรดแก้ไขข้อมูลในการ์ดปัจจุบันก่อน',
|
||||
lookup: {
|
||||
loading: 'กำลังโหลดแค็ตตาล็อก',
|
||||
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
|
||||
stale: 'แค็ตตาล็อกนี้เป็น snapshot เก่า ยังเลือกได้ แต่การตรวจสอบฝั่งหลังบ้านเป็นผลสุดท้าย',
|
||||
warning: 'หมายเหตุแค็ตตาล็อก: {warning}',
|
||||
error: 'แค็ตตาล็อกไม่พร้อมใช้งานชั่วคราว โปรดรีเฟรชแล้วลองใหม่',
|
||||
catalogMeta: 'แหล่งที่มาแค็ตตาล็อก: {source}, เวอร์ชัน: {version}',
|
||||
accountDerived: 'จะแสดง Market: {market} / Source: {source} จากแค็ตตาล็อก โดยผลยืนยันจากหลังบ้านเป็นผลสุดท้าย',
|
||||
currentNotInCatalog: 'ค่าเดิม "{value}" ไม่อยู่ในแค็ตตาล็อกปัจจุบัน โปรดเลือกใหม่',
|
||||
},
|
||||
sourceMessage: {
|
||||
subject: 'หัวข้อ',
|
||||
sender: 'ผู้ส่ง',
|
||||
|
||||
@@ -406,6 +406,8 @@ export default {
|
||||
order: {
|
||||
summary: '订单摘要',
|
||||
taskQueue: '同订单任务队列',
|
||||
v4TaskTimeline: 'V4 订单任务时间线',
|
||||
v4TaskTimelineEmpty: '暂无 V4 订单任务。',
|
||||
sourceMessage: '来源消息',
|
||||
businessKeySource: '业务号来源',
|
||||
updatedAt: '最近更新',
|
||||
@@ -500,6 +502,16 @@ export default {
|
||||
cardConfirmed: '卡片已确认,详情已刷新。',
|
||||
reviewResolved: '复核已提交,详情已刷新。',
|
||||
validationFailed: '请先修正当前卡片字段。',
|
||||
lookup: {
|
||||
loading: '目录加载中',
|
||||
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
|
||||
stale: '当前目录为过期快照,仍可选择,最终以后端校验为准。',
|
||||
warning: '目录提示:{warning}',
|
||||
error: '目录暂不可用,请刷新后重试。',
|
||||
catalogMeta: '目录来源:{source},版本:{version}',
|
||||
accountDerived: '选择后将参考目录展示 Market:{market} / Source:{source},最终以后端确认结果为准。',
|
||||
currentNotInCatalog: '原值“{value}”不在当前目录中,请重新选择。',
|
||||
},
|
||||
sourceMessage: {
|
||||
subject: '主题',
|
||||
sender: '发件人',
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
ReservationTaskPayloadMutationRequest,
|
||||
ReservationTaskPayloadMutationResult,
|
||||
ReservationV4CardConfirmRequest,
|
||||
ReservationV4CatalogLookupFilters,
|
||||
ReservationV4CatalogLookupResult,
|
||||
ReservationV4OrderTaskDetailResult,
|
||||
ReservationV4OrderTaskListFilters,
|
||||
ReservationV4OrderTaskListResult,
|
||||
@@ -67,6 +69,30 @@ export async function fetchReservationV4OrderTasks(
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchReservationV4AccountLookups(
|
||||
filters: ReservationV4CatalogLookupFilters = {},
|
||||
): Promise<ReservationV4CatalogLookupResult> {
|
||||
return getJson<ReservationV4CatalogLookupResult>(
|
||||
withQuery('/api/reservation/lookups/accounts', withReservationHotel(filters)),
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchReservationV4RoomTypeLookups(
|
||||
filters: ReservationV4CatalogLookupFilters = {},
|
||||
): Promise<ReservationV4CatalogLookupResult> {
|
||||
return getJson<ReservationV4CatalogLookupResult>(
|
||||
withQuery('/api/reservation/lookups/room-types', withReservationHotel(filters)),
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchReservationV4RateCodeLookups(
|
||||
filters: ReservationV4CatalogLookupFilters = {},
|
||||
): Promise<ReservationV4CatalogLookupResult> {
|
||||
return getJson<ReservationV4CatalogLookupResult>(
|
||||
withQuery('/api/reservation/lookups/rate-codes', withReservationHotel(filters)),
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchReservationOrderDetail(orderId: string): Promise<ReservationOrderDetailResult> {
|
||||
return getJson<ReservationOrderDetailResult>(
|
||||
withQuery(`/api/reservation/orders/${encodeURIComponent(orderId)}`, {
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
fetchReservationOrders,
|
||||
fetchReservationTaskDetail,
|
||||
fetchReservationTaskList,
|
||||
fetchReservationV4AccountLookups,
|
||||
fetchReservationV4OrderTaskDetail,
|
||||
fetchReservationV4OrderTasks,
|
||||
fetchReservationV4RateCodeLookups,
|
||||
fetchReservationV4RoomTypeLookups,
|
||||
fetchReservationV4SourceNotificationDetail,
|
||||
fetchReservationV4WorkbenchItems,
|
||||
fetchSourceMessageConversation,
|
||||
@@ -188,6 +191,85 @@ describe('reservationService real API mode', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches V4 catalog lookups with selected hotel and query params', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(mockJsonResponse({
|
||||
hotel_id: 'HOTEL-BKK',
|
||||
catalog_type: 'ACCOUNT',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
catalog_version: 'v1',
|
||||
stale: false,
|
||||
items: [],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
},
|
||||
warnings: [],
|
||||
}))
|
||||
.mockResolvedValueOnce(mockJsonResponse({
|
||||
hotel_id: 'HOTEL-BKK',
|
||||
catalog_type: 'ROOM_TYPE',
|
||||
catalog_source: 'PMS_SYNC',
|
||||
catalog_version: 'v2',
|
||||
stale: true,
|
||||
items: [],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
},
|
||||
warnings: ['PMS sync stale'],
|
||||
}))
|
||||
.mockResolvedValueOnce(mockJsonResponse({
|
||||
hotel_id: 'HOTEL-BKK',
|
||||
catalog_type: 'RATE_CODE',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
catalog_version: 'v3',
|
||||
stale: false,
|
||||
items: [],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
},
|
||||
warnings: [],
|
||||
}))
|
||||
setReservationHotelIdProvider(() => 'HOTEL-BKK')
|
||||
|
||||
await fetchReservationV4AccountLookups({
|
||||
keyword: 'QBD',
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
await fetchReservationV4RoomTypeLookups({
|
||||
keyword: 'RM',
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
await fetchReservationV4RateCodeLookups({
|
||||
keyword: 'BAR',
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/reservation/lookups/accounts?hotel_id=HOTEL-BKK&keyword=QBD&page_num=1&page_size=20',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/reservation/lookups/room-types?hotel_id=HOTEL-BKK&keyword=RM&page_num=1&page_size=20',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/reservation/lookups/rate-codes?hotel_id=HOTEL-BKK&keyword=BAR&page_num=1&page_size=20',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches V4 order task and source notification details by id', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(mockJsonResponse({ order_task: { order_task_id: '9001' } }))
|
||||
@@ -482,6 +564,7 @@ describe('reservationService real API mode', () => {
|
||||
updated_at: '2026-07-08T03:10:00Z',
|
||||
},
|
||||
tasks: [],
|
||||
v4_order_tasks: [],
|
||||
warnings: [],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildReservationV4ReviewOverrides,
|
||||
isReservationV4ConfirmWritableField,
|
||||
isReservationV4ReviewWritableField,
|
||||
reservationV4LookupKindForOptionsSource,
|
||||
stringifyReservationV4SafeDisplayValue,
|
||||
validateReservationV4Fields,
|
||||
} from '@/utils/reservationV4FieldRules'
|
||||
@@ -188,4 +189,11 @@ describe('reservationV4FieldRules', () => {
|
||||
'/basic_information/account_code': 'Account is required',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps V4 options_source to lookup kinds without returning fixed catalog options', () => {
|
||||
expect(reservationV4LookupKindForOptionsSource('reservation_v4_account_catalog')).toBe('ACCOUNT')
|
||||
expect(reservationV4LookupKindForOptionsSource('RESERVATION_V4_ROOM_TYPE_CATALOG')).toBe('ROOM_TYPE')
|
||||
expect(reservationV4LookupKindForOptionsSource('reservation_v4_rate_code_catalog')).toBe('RATE_CODE')
|
||||
expect(reservationV4LookupKindForOptionsSource('static_enum')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,10 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||
...actual,
|
||||
ackReservationV4SourceNotification: vi.fn(),
|
||||
confirmReservationV4OrderTaskCard: vi.fn(),
|
||||
fetchReservationV4AccountLookups: vi.fn(),
|
||||
fetchReservationV4OrderTaskDetail: vi.fn(),
|
||||
fetchReservationV4RateCodeLookups: vi.fn(),
|
||||
fetchReservationV4RoomTypeLookups: vi.fn(),
|
||||
fetchReservationV4SourceNotificationDetail: vi.fn(),
|
||||
resolveReservationV4OrderTaskCardReview: vi.fn(),
|
||||
}
|
||||
@@ -33,12 +36,16 @@ describe('reservation V4 pages', () => {
|
||||
sessionStorage.clear()
|
||||
vi.mocked(service.ackReservationV4SourceNotification).mockReset()
|
||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockReset()
|
||||
vi.mocked(service.fetchReservationV4AccountLookups).mockReset()
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockReset()
|
||||
vi.mocked(service.fetchReservationV4RateCodeLookups).mockReset()
|
||||
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockReset()
|
||||
vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockReset()
|
||||
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockReset()
|
||||
mockCatalogLookups()
|
||||
})
|
||||
|
||||
it('renders V4 order task cards and confirms only editable basic information fields', async () => {
|
||||
it('renders V4 order task cards, loads lookup options and confirms only selected codes', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
|
||||
@@ -60,8 +67,21 @@ describe('reservation V4 pages', () => {
|
||||
expect(wrapper.text()).toContain('基础信息卡')
|
||||
expect(wrapper.text()).toContain('业务任务卡')
|
||||
expect(wrapper.text()).toContain('Backend contract issue')
|
||||
expect(service.fetchReservationV4AccountLookups).toHaveBeenCalledWith({
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
})
|
||||
expect(service.fetchReservationV4RoomTypeLookups).toHaveBeenCalledWith({
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
})
|
||||
|
||||
await wrapper.find('select').setValue('HANATOUR')
|
||||
await wrapper.find('select').setValue('ACC-LIVE')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('LEISURE')
|
||||
expect(wrapper.text()).toContain('TRAVEL_AGENT')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -69,7 +89,7 @@ describe('reservation V4 pages', () => {
|
||||
version: 7,
|
||||
confirmed_payload: {
|
||||
basic_information: {
|
||||
account_code: 'HANATOUR',
|
||||
account_code: 'ACC-LIVE',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -153,6 +173,74 @@ describe('reservation V4 pages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows non-blocking lookup empty, stale and warning states', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
catalog_type: 'ACCOUNT',
|
||||
catalog_source: 'PMS_SYNC',
|
||||
catalog_version: 'stale-v1',
|
||||
stale: true,
|
||||
items: [],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
total: 0,
|
||||
},
|
||||
warnings: ['PMS sync is stale'],
|
||||
})
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.empty)
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.stale)
|
||||
expect(wrapper.text()).toContain('PMS sync is stale')
|
||||
expect(wrapper.text()).toContain('PMS_SYNC')
|
||||
expect(wrapper.text()).toContain('stale-v1')
|
||||
})
|
||||
|
||||
it('does not submit a catalog field value that is missing from the loaded lookup result', async () => {
|
||||
const detail = createOrderTaskDetail({
|
||||
basicAccountValue: 'UNKNOWN_ACCOUNT',
|
||||
basicAccountRequired: true,
|
||||
})
|
||||
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
catalog_type: 'ACCOUNT',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
catalog_version: 'catalog-v1',
|
||||
stale: false,
|
||||
items: [],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
total: 0,
|
||||
},
|
||||
warnings: [],
|
||||
})
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('UNKNOWN_ACCOUNT')
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.currentNotInCatalog.replace('{value}', 'UNKNOWN_ACCOUNT'))
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.validationFailed)
|
||||
})
|
||||
|
||||
it('requires confirmed order id before resolving an unresolved V4 review card', async () => {
|
||||
const detail = createOrderTaskDetail({
|
||||
businessCardStatus: 'REVIEW_REQUIRED',
|
||||
@@ -277,10 +365,83 @@ async function mountWithPlugins(component: object, initialPath: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function mockCatalogLookups() {
|
||||
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
catalog_type: 'ACCOUNT',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
catalog_version: 'catalog-v1',
|
||||
stale: false,
|
||||
items: [
|
||||
{
|
||||
code: 'ACC-LIVE',
|
||||
display_name: 'Live Account',
|
||||
status: 'ACTIVE',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
market_code: 'LEISURE',
|
||||
market_name: 'Leisure',
|
||||
source_code: 'TRAVEL_AGENT',
|
||||
source_name: 'Travel Agent',
|
||||
adult_capacity: null,
|
||||
pricing_available: null,
|
||||
},
|
||||
],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
total: 1,
|
||||
},
|
||||
warnings: [],
|
||||
})
|
||||
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockResolvedValue({
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
catalog_type: 'ROOM_TYPE',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
catalog_version: 'catalog-v1',
|
||||
stale: false,
|
||||
items: [
|
||||
{
|
||||
code: 'TWN',
|
||||
display_name: 'Twin',
|
||||
status: 'ACTIVE',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
market_code: null,
|
||||
market_name: null,
|
||||
source_code: null,
|
||||
source_name: null,
|
||||
adult_capacity: 2,
|
||||
pricing_available: null,
|
||||
},
|
||||
],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
total: 1,
|
||||
},
|
||||
warnings: [],
|
||||
})
|
||||
vi.mocked(service.fetchReservationV4RateCodeLookups).mockResolvedValue({
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
catalog_type: 'RATE_CODE',
|
||||
catalog_source: 'SYSTEM_MANAGED',
|
||||
catalog_version: 'catalog-v1',
|
||||
stale: false,
|
||||
items: [],
|
||||
page: {
|
||||
page_num: 1,
|
||||
page_size: 100,
|
||||
total: 0,
|
||||
},
|
||||
warnings: [],
|
||||
})
|
||||
}
|
||||
|
||||
function createOrderTaskDetail(options: {
|
||||
businessCardStatus?: string
|
||||
businessCardAvailability?: Partial<ReservationV4TaskCardResult['availability']>
|
||||
sourceDisplayPayload?: ReservationV4TaskCardResult['display_payload']
|
||||
basicAccountValue?: string
|
||||
basicAccountRequired?: boolean
|
||||
} = {}): ReservationV4OrderTaskDetailResult {
|
||||
const sourceCard = createCard('card-source', 'SOURCE_MESSAGE_DISPLAY', 'READONLY', {
|
||||
fields: [],
|
||||
@@ -301,7 +462,8 @@ function createOrderTaskDetail(options: {
|
||||
fields: [
|
||||
createField('/basic_information/account_code', {
|
||||
display_name: 'Account',
|
||||
value: '',
|
||||
value: options.basicAccountValue ?? '',
|
||||
required: options.basicAccountRequired ?? false,
|
||||
options_source: 'RESERVATION_V4_ACCOUNT_CATALOG',
|
||||
control_type: 'SELECT',
|
||||
}),
|
||||
|
||||
@@ -270,6 +270,35 @@ function createOrderDetailResult(displayName = 'GRP-001') {
|
||||
created_at: '2026-07-08T04:00:00Z',
|
||||
},
|
||||
],
|
||||
v4_order_tasks: [
|
||||
{
|
||||
order_task_id: '9001',
|
||||
order_ref: 'order-ref-1',
|
||||
order_task_status: 'OPEN',
|
||||
card_counts: {
|
||||
total_count: 3,
|
||||
readonly_count: 1,
|
||||
pending_confirm_count: 1,
|
||||
review_required_count: 1,
|
||||
confirmed_count: 0,
|
||||
},
|
||||
source_message_summary: {
|
||||
source_message_id: '30003',
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
external_message_id: 'm-v4-1',
|
||||
external_conversation_id: 'thread-v4-1',
|
||||
subject: 'V4 Booking Package',
|
||||
sender_summary: 'v4@example.test',
|
||||
received_at: '2026-07-08T05:00:00Z',
|
||||
source_sent_at: null,
|
||||
conversation_message_count: 1,
|
||||
},
|
||||
source_received_at: '2026-07-08T05:00:00Z',
|
||||
created_at: '2026-07-08T05:00:00Z',
|
||||
updated_at: '2026-07-08T05:10:00Z',
|
||||
latest_activity_at: '2026-07-08T05:10:00Z',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
@@ -461,6 +490,9 @@ describe('reservation P0 views', () => {
|
||||
expect(wrapper.text()).toContain('2')
|
||||
expect(wrapper.text()).toContain('前序任务未完成')
|
||||
expect(wrapper.text()).toContain('查看邮件会话')
|
||||
expect(wrapper.text()).toContain(zhCN.order.v4TaskTimeline)
|
||||
expect(wrapper.text()).toContain('order-ref-1')
|
||||
expect(wrapper.text()).toContain('V4 Booking Package')
|
||||
expect(wrapper.text()).not.toContain('PREVIOUS_TASK_NOT_FINISHED')
|
||||
expect(wrapper.text()).not.toContain('来源邮件摘要字段待后端补充')
|
||||
})
|
||||
|
||||
@@ -184,6 +184,39 @@ export interface ReservationV4OrderTaskListFilters {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export type ReservationV4CatalogType = 'ACCOUNT' | 'ROOM_TYPE' | 'RATE_CODE' | string
|
||||
|
||||
export interface ReservationV4CatalogLookupFilters {
|
||||
hotel_id?: string
|
||||
keyword?: string
|
||||
page_num?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface ReservationV4CatalogLookupItem {
|
||||
code: string
|
||||
display_name: string
|
||||
status: string | null
|
||||
catalog_source: string | null
|
||||
market_code: string | null
|
||||
market_name: string | null
|
||||
source_code: string | null
|
||||
source_name: string | null
|
||||
adult_capacity: number | null
|
||||
pricing_available: boolean | null
|
||||
}
|
||||
|
||||
export interface ReservationV4CatalogLookupResult {
|
||||
hotel_id: string
|
||||
catalog_type: ReservationV4CatalogType
|
||||
catalog_source: string | null
|
||||
catalog_version: string | null
|
||||
stale: boolean
|
||||
items: ReservationV4CatalogLookupItem[]
|
||||
page: ReservationPageResult
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface ReservationV4SourceMessageSummary {
|
||||
source_message_id: string
|
||||
hotel_id: string
|
||||
@@ -406,9 +439,22 @@ export interface ReservationOrderTaskTimelineItem {
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface ReservationV4OrderTaskTimelineItem {
|
||||
order_task_id: string
|
||||
order_ref: string | null
|
||||
order_task_status: ReservationV4OrderTaskStatus | string
|
||||
card_counts: ReservationV4CardCounts
|
||||
source_message_summary: ReservationV4SourceMessageSummary | null
|
||||
source_received_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
latest_activity_at: string | null
|
||||
}
|
||||
|
||||
export interface ReservationOrderDetailResult {
|
||||
order: ReservationOrderSummaryResult
|
||||
tasks: ReservationOrderTaskTimelineItem[]
|
||||
v4_order_tasks: ReservationV4OrderTaskTimelineItem[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const reviewWritablePointerPrefixes = ['/basic_information/', '/business_fields/
|
||||
const hiddenDisplayValue = Symbol('reservation-v4-hidden-display-value')
|
||||
|
||||
export type ReservationV4FieldErrorMap = Record<string, string>
|
||||
export type ReservationV4LookupKind = 'ACCOUNT' | 'ROOM_TYPE' | 'RATE_CODE'
|
||||
|
||||
export function reservationV4FieldKey(field: ReservationV4TaskCardFieldResult): string {
|
||||
return field.field_pointer || field.field_path
|
||||
@@ -163,34 +164,19 @@ export function mapReservationV4BackendDetailsToFields(
|
||||
}
|
||||
}
|
||||
|
||||
export function staticReservationV4Options(optionsSource: string | null): Array<{ value: string; label: string }> {
|
||||
export function reservationV4LookupKindForOptionsSource(optionsSource: string | null): ReservationV4LookupKind | null {
|
||||
switch (normalizeV4RuleCode(optionsSource)) {
|
||||
case 'RESERVATION_V4_ACCOUNT_CATALOG':
|
||||
case 'ACCOUNT_CATALOG':
|
||||
return [
|
||||
{ value: 'HANATOUR', label: 'HANATOUR' },
|
||||
{ value: 'LIAN_TAI', label: 'LIAN TAI' },
|
||||
{ value: 'QBD_TRAVEL', label: 'QBD Travel' },
|
||||
]
|
||||
return 'ACCOUNT'
|
||||
case 'RESERVATION_V4_ROOM_TYPE_CATALOG':
|
||||
case 'ROOM_TYPE_CATALOG':
|
||||
return [
|
||||
{ value: 'TWN', label: 'TWN' },
|
||||
{ value: 'DBL', label: 'DBL' },
|
||||
{ value: 'KING', label: 'KING' },
|
||||
{ value: 'SGL', label: 'SGL' },
|
||||
{ value: 'TRP', label: 'TRP' },
|
||||
]
|
||||
return 'ROOM_TYPE'
|
||||
case 'RESERVATION_V4_RATE_CODE_CATALOG':
|
||||
case 'RATE_CODE_CATALOG':
|
||||
return [
|
||||
{ value: 'BAR', label: 'BAR' },
|
||||
{ value: 'RACK', label: 'RACK' },
|
||||
{ value: 'GROUP', label: 'GROUP' },
|
||||
{ value: 'FIT', label: 'FIT' },
|
||||
]
|
||||
return 'RATE_CODE'
|
||||
default:
|
||||
return []
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,41 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="th-section side-section">
|
||||
<div class="th-section-header">
|
||||
<h2 class="th-section-title">
|
||||
{{ t('order.v4TaskTimeline') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="side-section__body">
|
||||
<p
|
||||
v-if="!v4OrderTasks.length"
|
||||
class="side-empty"
|
||||
>
|
||||
{{ t('order.v4TaskTimelineEmpty') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
v-for="item in v4OrderTasks"
|
||||
:key="item.order_task_id"
|
||||
class="v4-timeline-item"
|
||||
:to="`/reservation/order-tasks/${item.order_task_id}`"
|
||||
>
|
||||
<span>
|
||||
<strong>{{ v4OrderReference(item) }}</strong>
|
||||
<small>{{ item.source_message_summary?.subject ?? '-' }}</small>
|
||||
</span>
|
||||
<ReservationStatusBadge :status="item.order_task_status" />
|
||||
<small>
|
||||
{{ t('taskList.cardCountsTotal', { total: item.card_counts.total_count }) }}
|
||||
·
|
||||
{{ formatReservationDateTime(item.latest_activity_at ?? item.updated_at ?? item.created_at) }}
|
||||
</small>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="th-section source-gap">
|
||||
<h2>{{ t('task.evidence') }}</h2>
|
||||
<template v-if="activeTask">
|
||||
@@ -134,7 +169,7 @@ import ReservationTaskDetailPanel from '@/components/reservation/ReservationTask
|
||||
import ReservationTaskQueue from '@/components/reservation/ReservationTaskQueue.vue'
|
||||
import { fetchReservationOrderDetail } from '@/services/reservationService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { ReservationOrderDetailResult } from '@/types/reservation'
|
||||
import type { ReservationOrderDetailResult, ReservationV4OrderTaskTimelineItem } from '@/types/reservation'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -147,6 +182,7 @@ const activeTaskId = ref('')
|
||||
|
||||
const orderId = computed(() => String(route.params.orderId ?? ''))
|
||||
const activeTask = computed(() => detail.value?.tasks.find((task) => task.task_id === activeTaskId.value) ?? null)
|
||||
const v4OrderTasks = computed(() => detail.value?.v4_order_tasks ?? [])
|
||||
|
||||
watch(
|
||||
orderId,
|
||||
@@ -181,6 +217,10 @@ async function loadOrder(nextOrderId: string): Promise<void> {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function v4OrderReference(item: ReservationV4OrderTaskTimelineItem): string {
|
||||
return item.order_ref?.trim() || item.source_message_summary?.subject?.trim() || `#${item.order_task_id}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -295,6 +335,48 @@ async function loadOrder(nextOrderId: string): Promise<void> {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.side-empty {
|
||||
margin: 0;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.v4-timeline-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: inherit;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.v4-timeline-item > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.v4-timeline-item strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.v4-timeline-item small {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.v4-timeline-item > small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.source-gap dl {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -522,6 +522,7 @@ const TaskCardSection = defineComponent({
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user