diff --git a/client/src/components/reservation/ReservationV4RoomingListCard.vue b/client/src/components/reservation/ReservationV4RoomingListCard.vue new file mode 100644 index 0000000..82b58da --- /dev/null +++ b/client/src/components/reservation/ReservationV4RoomingListCard.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/client/src/components/reservation/ReservationV4TaskCardSection.vue b/client/src/components/reservation/ReservationV4TaskCardSection.vue index ae3ee54..5851ae5 100644 --- a/client/src/components/reservation/ReservationV4TaskCardSection.vue +++ b/client/src/components/reservation/ReservationV4TaskCardSection.vue @@ -46,8 +46,15 @@ @update:model-value="emit('update:modelValue', $event)" />
@@ -133,7 +140,7 @@ {{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }} @@ -155,7 +162,7 @@ {{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }} @@ -173,6 +180,7 @@ import { useI18n } from 'vue-i18n' import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue' import ReservationV4PaymentAttachmentPreview from '@/components/reservation/ReservationV4PaymentAttachmentPreview.vue' import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue' +import ReservationV4RoomingListCard from '@/components/reservation/ReservationV4RoomingListCard.vue' import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue' import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation' import { formatReservationReadonlyReason } from '@/utils/reservationDisplay' @@ -196,6 +204,8 @@ const props = withDefaults(defineProps<{ hotelId?: string orderTaskId?: string | null sourceMessageId?: string | null + orderReference?: string | null + targetLocator?: string | null actionErrors?: string[] successMessage?: string reviewForm?: ReviewForm @@ -203,11 +213,14 @@ const props = withDefaults(defineProps<{ canConfirm: boolean canReview: boolean boundOrderId?: string | null + boundOrderDisplayName?: string | null }>(), { validationErrors: () => ({}), hotelId: undefined, orderTaskId: null, sourceMessageId: null, + orderReference: null, + targetLocator: null, actionErrors: () => [], successMessage: '', reviewForm: () => ({ @@ -215,6 +228,7 @@ const props = withDefaults(defineProps<{ reason: '', }), boundOrderId: null, + boundOrderDisplayName: null, }) const emit = defineEmits<{ @@ -227,6 +241,7 @@ const emit = defineEmits<{ const { t } = useI18n() const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION') const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT') +const isRoomingListCard = computed(() => props.card.card_type === 'ROOMING_LIST') const visibleFields = computed(() => isPaymentCard.value ? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field)) diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 227df71..a7cf5da 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -623,6 +623,13 @@ export default { unavailableReason: 'Unavailable reason: {reason}', fileMeta: '{type} · {size}', }, + roomingList: { + title: 'Rooming List action', + targetOrder: 'Target order', + unboundOrder: 'No visible order is bound yet', + description: 'The current source message contains a Rooming List item that needs manual handling.', + confirmHint: 'This first version only confirms that the item has been handled manually. Guest details and attachment content are not displayed or processed here.', + }, lookup: { loading: 'Loading catalog', empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 292987a..f24cba4 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -623,6 +623,13 @@ export default { unavailableReason: 'เหตุผลที่ใช้ไม่ได้: {reason}', fileMeta: '{type} · {size}', }, + roomingList: { + title: 'งาน Rooming List', + targetOrder: 'ออเดอร์เป้าหมาย', + unboundOrder: 'ยังไม่มีออเดอร์ที่มองเห็นได้ผูกอยู่', + description: 'ข้อความต้นทางปัจจุบันมีรายการ Rooming List ที่ต้องให้เจ้าหน้าที่จัดการ', + confirmHint: 'เวอร์ชันแรกนี้ใช้ยืนยันว่ารายการได้รับการจัดการด้วยคนแล้วเท่านั้น ไม่แสดงหรือประมวลผลรายชื่อแขกและเนื้อหาไฟล์แนบในหน้านี้', + }, lookup: { loading: 'กำลังโหลดแค็ตตาล็อก', empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index e95c48b..f1a85b4 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -623,6 +623,13 @@ export default { unavailableReason: '不可用原因:{reason}', fileMeta: '{type} · {size}', }, + roomingList: { + title: 'Rooming List / 房表事项', + targetOrder: '目标订单', + unboundOrder: '暂未绑定可见订单', + description: '当前来源消息包含需要人工处理的 Rooming List 事项,请在酒店内部流程处理后确认该卡片。', + confirmHint: '第一版仅确认该事项已由人工处理,不展示或处理名单明细和附件内容。', + }, lookup: { loading: '目录加载中', empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。', diff --git a/client/src/tests/reservationV4Views.spec.ts b/client/src/tests/reservationV4Views.spec.ts index b06a566..91d2d00 100644 --- a/client/src/tests/reservationV4Views.spec.ts +++ b/client/src/tests/reservationV4Views.spec.ts @@ -386,6 +386,134 @@ describe('reservation V4 pages', () => { expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') }) + it('renders Rooming List cards as lightweight action items without rows, attachments or raw payloads', async () => { + const detail = createOrderTaskDetail() + useRoomingListBusinessCard(detail) + useBoundOrder(detail) + vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) + + const wrapper = await mountWithPlugins( + ReservationV4OrderTaskDetailView, + '/reservation/order-tasks/9001', + ) + await flushPromises() + + const roomingListSection = wrapper + .findAll('.task-card-section') + .find((section) => section.find('[data-testid="rooming-list-card"]').exists()) + expect(roomingListSection).toBeTruthy() + const roomingListCard = roomingListSection!.find('[data-testid="rooming-list-card"]') + expect(roomingListCard.exists()).toBe(true) + expect(roomingListSection!.text()).toContain(zhCN.taskV4.roomingList.title) + expect(roomingListCard.text()).toContain(zhCN.taskV4.roomingList.description) + expect(roomingListCard.text()).toContain('GRP-001') + expect(roomingListSection!.findAll('a').filter((link) => link.text() === zhCN.task.viewOrder)).toHaveLength(1) + expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessageCard) + expect(wrapper.text().indexOf(zhCN.taskV4.sourceMessageCard)).toBeGreaterThan( + wrapper.text().indexOf(zhCN.taskV4.roomingList.title), + ) + + expect(roomingListSection!.find('details.safe-payload').exists()).toBe(false) + expect(roomingListSection!.text()).not.toContain('ROW_SHOULD_NOT_RENDER') + expect(roomingListSection!.text()).not.toContain('attachment_ids') + expect(roomingListSection!.text()).not.toContain('https://oss.example/private/rooming-list.xlsx') + expect(roomingListSection!.text()).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER') + expect(roomingListSection!.text()).not.toContain('PMS_IMPORT_SHOULD_NOT_RENDER') + expect(roomingListSection!.text()).not.toContain('OHIP_SHOULD_NOT_RENDER') + expect(roomingListSection!.text()).not.toContain('Excel') + expect(wrapper.find('[data-testid="payment-attachment-preview"]').exists()).toBe(false) + }) + + it('shows an explanatory unbound order label for Rooming List cards without target order clues', async () => { + const detail = createOrderTaskDetail() + useRoomingListBusinessCard(detail) + detail.order_task.display_order_key = null + detail.order_task.order_ref = null + detail.order_task.target_locator_type = null + detail.order_task.target_locator_value = null + vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) + + const wrapper = await mountWithPlugins( + ReservationV4OrderTaskDetailView, + '/reservation/order-tasks/9001', + ) + await flushPromises() + + const roomingListCard = wrapper.find('[data-testid="rooming-list-card"]') + expect(roomingListCard.text()).toContain(zhCN.taskV4.roomingList.unboundOrder) + }) + + it('confirms Rooming List cards with version only and applies the refreshed backend detail', async () => { + const detail = createOrderTaskDetail() + useRoomingListBusinessCard(detail) + const confirmedDetail = createOrderTaskDetail() + useRoomingListBusinessCard(confirmedDetail, 'CONFIRMED') + vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) + vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue(confirmedDetail) + + const wrapper = await mountWithPlugins( + ReservationV4OrderTaskDetailView, + '/reservation/order-tasks/9001', + ) + await flushPromises() + + const roomingListSection = wrapper + .findAll('.task-card-section') + .find((section) => section.find('[data-testid="rooming-list-card"]').exists()) + expect(roomingListSection).toBeTruthy() + await roomingListSection!.find('button.primary-button').trigger('click') + await flushPromises() + + expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-rooming-list', { + version: 12, + }) + const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2] + expect(JSON.stringify(submittedRequest)).not.toContain('confirmed_payload') + expect(JSON.stringify(submittedRequest)).not.toContain('rows') + expect(JSON.stringify(submittedRequest)).not.toContain('attachment_ids') + expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') + expect(wrapper.text()).toContain(zhCN.status.CONFIRMED) + }) + + it('resolves Rooming List review cards without submitting row or attachment overrides', async () => { + const detail = createOrderTaskDetail() + useRoomingListBusinessCard(detail, 'REVIEW_REQUIRED') + detail.order_task.order_id = 'order-2001' + detail.order_task.target_resolution_status = 'RESOLVED' + const resolvedDetail = createOrderTaskDetail() + useRoomingListBusinessCard(resolvedDetail, 'PENDING_CONFIRM') + vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) + vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockResolvedValue(resolvedDetail) + + const wrapper = await mountWithPlugins( + ReservationV4OrderTaskDetailView, + '/reservation/order-tasks/9001', + ) + await flushPromises() + + const roomingListSection = wrapper + .findAll('.task-card-section') + .find((section) => section.find('[data-testid="rooming-list-card"]').exists()) + expect(roomingListSection).toBeTruthy() + expect(roomingListSection!.find('.review-box').exists()).toBe(true) + expect(roomingListSection!.find('.task-card-section__fields').exists()).toBe(false) + await roomingListSection!.find('textarea').setValue('handled rooming list') + await roomingListSection!.find('button.primary-button').trigger('click') + await flushPromises() + + expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled() + expect(service.resolveReservationV4OrderTaskCardReview).toHaveBeenCalledWith('9001', 'card-rooming-list', { + version: 12, + confirmed_order_id: 'order-2001', + reason: 'handled rooming list', + field_overrides: [], + }) + const submittedRequest = vi.mocked(service.resolveReservationV4OrderTaskCardReview).mock.calls[0]?.[2] + expect(JSON.stringify(submittedRequest)).not.toContain('rows') + expect(JSON.stringify(submittedRequest)).not.toContain('attachment_ids') + expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') + }) + it('renders New Booking Room Information as a business form and confirms stable final values', async () => { const detail = createOrderTaskDetail({ businessEventType: 'NEW_BOOKING', @@ -1360,6 +1488,75 @@ function usePaymentBusinessCard(detail: ReservationV4OrderTaskDetailResult): voi ] } +function useRoomingListBusinessCard( + detail: ReservationV4OrderTaskDetailResult, + cardStatus = 'PENDING_CONFIRM', +): void { + const isPendingConfirm = cardStatus === 'PENDING_CONFIRM' + const isReviewRequired = cardStatus === 'REVIEW_REQUIRED' + detail.business_cards = [ + createCard('card-rooming-list', 'ROOMING_LIST', cardStatus, { + version: 12, + availability: createAvailability({ + confirmable: isPendingConfirm, + reviewable: isReviewRequired, + editable: false, + read_only: !isPendingConfirm, + }), + display_payload: { + rows: [ + { + guest_name: 'ROW_SHOULD_NOT_RENDER', + room_no: '1001', + }, + ], + attachment_ids: ['rooming-list-att-1'], + attachment_url: 'https://oss.example/private/rooming-list.xlsx', + ai_payload_json: 'AI_PAYLOAD_SHOULD_NOT_RENDER', + pms_import_plan: 'PMS_IMPORT_SHOULD_NOT_RENDER', + ohip_payload: 'OHIP_SHOULD_NOT_RENDER', + }, + fields: [ + createField('/rooming_list/rows/0/guest_name', { + display_name: 'Rooming list row', + value: 'ROW_SHOULD_NOT_RENDER', + editable: true, + edit_scope: 'CONFIRM', + write_target: 'CONFIRMED_PAYLOAD_JSON', + }), + createField('/rooming_list/attachment_ids', { + display_name: 'attachment_ids', + value: ['rooming-list-att-1'], + editable: true, + edit_scope: 'CONFIRM', + write_target: 'CONFIRMED_PAYLOAD_JSON', + }), + ], + }), + ] + detail.card_counts = { + ...detail.card_counts, + pending_confirm_count: cardStatus === 'PENDING_CONFIRM' ? 2 : 1, + confirmed_count: cardStatus === 'CONFIRMED' ? 1 : 0, + } +} + +function useBoundOrder(detail: ReservationV4OrderTaskDetailResult): void { + detail.bound_order = { + order_id: 'order-2001', + hotel_id: 'HOTEL-TEST', + order_status: 'ACTIVE', + temporary_order_no: null, + confirmation_number: null, + group_code: 'GRP-001', + block_code: null, + allotment_code: null, + display_name: 'GRP-001', + created_at: '2026-07-08T03:00:00Z', + updated_at: '2026-07-08T03:00:00Z', + } +} + function createPaymentConversationMedia(): SourceMessageOriginalMedia[] { return [ { diff --git a/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue b/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue index fbbf980..a7eab54 100644 --- a/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue +++ b/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue @@ -89,6 +89,8 @@ :hotel-id="detail.order_task.hotel_id" :order-task-id="detail.order_task.order_task_id" :source-message-id="detail.order_task.source_message_id" + :order-reference="displayOrderReference" + :target-locator="targetLocator" :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]" @@ -96,6 +98,7 @@ :can-confirm="canConfirmCard(detail.basic_information_card)" :can-review="canReviewCard(detail.basic_information_card)" :bound-order-id="detail.bound_order?.order_id" + :bound-order-display-name="detail.bound_order?.display_name" @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)" @@ -132,6 +135,8 @@ :hotel-id="detail.order_task.hotel_id" :order-task-id="detail.order_task.order_task_id" :source-message-id="detail.order_task.source_message_id" + :order-reference="displayOrderReference" + :target-locator="targetLocator" :action-errors="cardActionErrors[businessCard.card_id] ?? []" :success-message="cardSuccessMessages[businessCard.card_id] ?? ''" :review-form="reviewForms[businessCard.card_id]" @@ -139,6 +144,7 @@ :can-confirm="canConfirmCard(businessCard)" :can-review="canReviewCard(businessCard)" :bound-order-id="detail.bound_order?.order_id" + :bound-order-display-name="detail.bound_order?.display_name" @update:model-value="setCardValues(businessCard, $event)" @update:review-form="setReviewForm(businessCard.card_id, $event)" @confirm="submitConfirm(businessCard)" @@ -459,7 +465,7 @@ function editableFieldKeys(card: ReservationV4TaskCardResult): string[] { } function submissionFieldsForCard(card: ReservationV4TaskCardResult): ReservationV4TaskCardResult['fields'] { - if (isPaymentCard(card)) { + if (isVersionOnlyConfirmCard(card)) { return [] } return card.card_type === 'ROOM_INFORMATION' @@ -473,7 +479,7 @@ function buildConfirmRequest( card: ReservationV4TaskCardResult, submissionFields: ReservationV4TaskCardResult['fields'], ): ReservationV4CardConfirmRequest { - if (isPaymentCard(card)) { + if (isVersionOnlyConfirmCard(card)) { return { version: card.version, } @@ -488,6 +494,14 @@ function isPaymentCard(card: ReservationV4TaskCardResult): boolean { return card.card_type === 'PAYMENT' } +function isRoomingListCard(card: ReservationV4TaskCardResult): boolean { + return card.card_type === 'ROOMING_LIST' +} + +function isVersionOnlyConfirmCard(card: ReservationV4TaskCardResult): boolean { + return isPaymentCard(card) || isRoomingListCard(card) +} + function isGroupRoomInformationBreakfastField( card: ReservationV4TaskCardResult, field: ReservationV4TaskCardResult['fields'][number], @@ -521,6 +535,9 @@ function applyCardActionError(card: ReservationV4TaskCardResult, error: unknown, } function cardTitle(card: ReservationV4TaskCardResult): string { + if (isRoomingListCard(card)) { + return t('taskV4.roomingList.title') + } return formatReservationTaskCard(t, card.card_type) }