Compare commits
2 Commits
fb980c6814
...
08c8b0165b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08c8b0165b | ||
|
|
434afeb7c5 |
124
client/src/components/common/LocalizedDatePicker.vue
Normal file
124
client/src/components/common/LocalizedDatePicker.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<DatePicker
|
||||
v-model="dateModel"
|
||||
:input-id="inputId"
|
||||
class="localized-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
:placeholder="placeholder"
|
||||
:input-class="inputClass"
|
||||
:pt="datePickerPt"
|
||||
:invalid="invalid"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
show-icon
|
||||
icon-display="input"
|
||||
append-to="body"
|
||||
:panel-class="panelClass"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
import { usePrimeVue } from 'primevue/config'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
formatDatePickerValue,
|
||||
normalizeDatePickerLocale,
|
||||
parseDatePickerValue,
|
||||
primeVueDatePickerLocales,
|
||||
} from '@/utils/localizedDatePicker'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
inputId: string
|
||||
name?: string
|
||||
testId: string
|
||||
panelTestId?: string
|
||||
placeholder?: string
|
||||
inputClass?: string | Record<string, boolean>
|
||||
invalid?: boolean
|
||||
describedBy?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
panelClass?: string
|
||||
}>(), {
|
||||
name: undefined,
|
||||
panelTestId: undefined,
|
||||
placeholder: '',
|
||||
inputClass: undefined,
|
||||
invalid: false,
|
||||
describedBy: undefined,
|
||||
disabled: false,
|
||||
required: false,
|
||||
panelClass: 'localized-date-picker-panel',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const primeVue = usePrimeVue()
|
||||
|
||||
const dateModel = computed<Date | null>({
|
||||
get: () => parseDatePickerValue(props.modelValue),
|
||||
set: (value) => {
|
||||
emit('update:modelValue', formatDatePickerValue(value))
|
||||
},
|
||||
})
|
||||
|
||||
const datePickerPt = computed(() => ({
|
||||
pcInputText: {
|
||||
root: {
|
||||
name: props.name,
|
||||
'data-testid': props.testId,
|
||||
lang: locale.value,
|
||||
'aria-invalid': props.invalid ? 'true' : undefined,
|
||||
'aria-describedby': props.describedBy,
|
||||
},
|
||||
},
|
||||
panel: {
|
||||
lang: locale.value,
|
||||
'data-testid': props.panelTestId ?? `${props.testId}-panel`,
|
||||
},
|
||||
}))
|
||||
|
||||
watch(
|
||||
locale,
|
||||
(nextLocale) => {
|
||||
const supportedLocale = normalizeDatePickerLocale(nextLocale)
|
||||
primeVue.config.locale = {
|
||||
...(primeVue.config.locale ?? primeVueDatePickerLocales['en-US']),
|
||||
...primeVueDatePickerLocales[supportedLocale],
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.localized-date-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.localized-date-picker :deep(.p-inputtext) {
|
||||
width: 100%;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.localized-date-picker :deep(.p-datepicker-input-icon-container) {
|
||||
right: 12px;
|
||||
color: var(--th-color-navy-950);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.localized-date-picker-panel {
|
||||
z-index: 1200;
|
||||
color: var(--th-color-navy-950);
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -7,9 +7,64 @@
|
||||
{{ t('taskV4.roomInformation.noDisplayModel') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="room-information__chips">
|
||||
<span>{{ eventTypeLabel }}</span>
|
||||
<span>{{ bookingTypeLabel }}</span>
|
||||
<div class="room-information__header">
|
||||
<div class="room-information__chips">
|
||||
<span>{{ eventTypeLabel }}</span>
|
||||
<span>{{ bookingTypeLabel }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showGroupBookingStatusSlot"
|
||||
class="room-information__status-slot"
|
||||
>
|
||||
<label
|
||||
v-if="groupBookingStatusEditable && groupBookingStatusField"
|
||||
class="room-information__status-field"
|
||||
>
|
||||
<span>
|
||||
{{ t('taskV4.roomInformation.groupBookingStatus') }}
|
||||
<sup v-if="groupBookingStatusRequired">*</sup>
|
||||
</span>
|
||||
<select
|
||||
name="/room_information/final_values/group_booking_status"
|
||||
:aria-invalid="groupBookingStatusErrors.length ? 'true' : 'false'"
|
||||
:value="groupBookingStatusValue"
|
||||
@change="updateGroupBookingStatus"
|
||||
>
|
||||
<option value="">{{ t('taskV4.field.selectPlaceholder') }}</option>
|
||||
<option
|
||||
v-for="option in groupBookingStatusOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<small
|
||||
v-for="error in groupBookingStatusErrors"
|
||||
:key="error"
|
||||
class="room-information__status-error"
|
||||
>
|
||||
{{ error }}
|
||||
</small>
|
||||
</label>
|
||||
<div
|
||||
v-else
|
||||
class="room-information__status-badge"
|
||||
>
|
||||
<span>
|
||||
{{ t('taskV4.roomInformation.groupBookingStatus') }}
|
||||
<sup v-if="groupBookingStatusRequired">*</sup>
|
||||
</span>
|
||||
<strong>{{ groupBookingStatusLabel }}</strong>
|
||||
<small
|
||||
v-for="error in groupBookingStatusErrors"
|
||||
:key="error"
|
||||
class="room-information__status-error"
|
||||
>
|
||||
{{ error }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
@@ -128,38 +183,9 @@
|
||||
|
||||
<section class="room-information__panel">
|
||||
<h3>{{ t('taskV4.roomInformation.finalValues') }}</h3>
|
||||
<div class="room-information__derived">
|
||||
<div
|
||||
v-if="hasValue(finalValues.nights)"
|
||||
class="room-information__derived-item"
|
||||
>
|
||||
<span>{{ t('taskV4.roomInformation.nights') }}</span>
|
||||
<strong>{{ formatRoomInformationValue('nights', finalValues.nights, finalValues) }}</strong>
|
||||
</div>
|
||||
<label
|
||||
v-if="showReadonlyBreakfast"
|
||||
class="room-information__derived-item room-information__derived-item--checkbox"
|
||||
>
|
||||
<span>{{ t('taskV4.roomInformation.breakfastIncluded') }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="/room_information/final_values/breakfast_included"
|
||||
:checked="finalValues.breakfast_included === true"
|
||||
disabled
|
||||
>
|
||||
</label>
|
||||
<div
|
||||
v-if="hasValue(finalValues.group_booking_status) && !hasEditableField('/room_information/final_values/group_booking_status')"
|
||||
class="room-information__derived-item"
|
||||
>
|
||||
<span>{{ t('taskV4.roomInformation.groupBookingStatus') }}</span>
|
||||
<strong>{{ formatRoomInformationValue('group_booking_status', finalValues.group_booking_status, finalValues) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReservationV4TaskCardFieldRenderer
|
||||
v-if="showEditableFields"
|
||||
:fields="visibleFields"
|
||||
:fields="finalDisplayFields"
|
||||
:model-value="modelValue"
|
||||
:read-only="readOnly"
|
||||
:editable-keys="editableKeys"
|
||||
@@ -169,11 +195,11 @@
|
||||
/>
|
||||
<template v-else>
|
||||
<dl
|
||||
v-if="scalarEntries(finalValues).length"
|
||||
v-if="finalScalarEntries.length"
|
||||
class="room-information__grid"
|
||||
>
|
||||
<template
|
||||
v-for="entry in scalarEntries(finalValues)"
|
||||
v-for="entry in finalScalarEntries"
|
||||
:key="entry.key"
|
||||
>
|
||||
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
|
||||
@@ -225,10 +251,13 @@ import type {
|
||||
ReservationV4RoomInformationDisplayModel,
|
||||
ReservationV4RoomInformationValues,
|
||||
ReservationV4TaskCardResult,
|
||||
ReservationV4TaskCardFieldResult,
|
||||
} from '@/types/reservation'
|
||||
import {
|
||||
reservationV4FieldKey,
|
||||
isReservationV4RoomInformationSafeField,
|
||||
isReservationV4EditableField,
|
||||
readV4FieldValue,
|
||||
stringifyReservationV4SafeDisplayValue,
|
||||
} from '@/utils/reservationV4FieldRules'
|
||||
|
||||
@@ -287,9 +316,87 @@ const changeSummary = computed(() => roomInformation.value?.change_summary ?? []
|
||||
const visibleChangeSummary = computed(() => changeSummary.value.filter((change) =>
|
||||
isVisibleRoomInformationChangeField(change.field),
|
||||
))
|
||||
const visibleFields = computed(() => props.card.fields.filter((field) =>
|
||||
type FieldOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const roomInformationFields = computed(() => props.card.fields.filter((field) =>
|
||||
isReservationV4RoomInformationSafeField(field) && isVisibleRoomInformationField(field),
|
||||
))
|
||||
const groupBookingStatusField = computed(() =>
|
||||
roomInformationFields.value.find((field) => roomInformationFieldKey(field) === 'group_booking_status') ?? null,
|
||||
)
|
||||
const groupBookingStatusEditable = computed(() => {
|
||||
const field = groupBookingStatusField.value
|
||||
return Boolean(
|
||||
field &&
|
||||
!props.readOnly &&
|
||||
props.editableKeys.includes(reservationV4FieldKey(field)) &&
|
||||
isReservationV4EditableField(field),
|
||||
)
|
||||
})
|
||||
const groupBookingStatusRequired = computed(() => groupBookingStatusField.value?.required === true)
|
||||
const groupBookingStatusErrors = computed(() => {
|
||||
const field = groupBookingStatusField.value
|
||||
if (!field) {
|
||||
return []
|
||||
}
|
||||
const key = reservationV4FieldKey(field)
|
||||
return [
|
||||
props.validationErrors[key],
|
||||
...(field.validation_errors ?? []),
|
||||
].filter((message): message is string => Boolean(message?.trim()))
|
||||
})
|
||||
const groupBookingStatusOptions = computed<FieldOption[]>(() => {
|
||||
const backendOptions = (groupBookingStatusField.value?.fixed_options ?? [])
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label || option.value,
|
||||
}))
|
||||
if (backendOptions.length) {
|
||||
return backendOptions
|
||||
}
|
||||
const displayOptions = roomInformation.value?.group_booking_status_options ?? []
|
||||
if (displayOptions.length) {
|
||||
return displayOptions.map((option) => ({
|
||||
value: option.code,
|
||||
label: option.label || option.code,
|
||||
}))
|
||||
}
|
||||
return [
|
||||
{
|
||||
value: 'TEN',
|
||||
label: 'TEN-Tentative',
|
||||
},
|
||||
{
|
||||
value: 'DEF',
|
||||
label: 'DEF-Definite',
|
||||
},
|
||||
{
|
||||
value: 'INQ',
|
||||
label: 'INQ-Inquiry',
|
||||
},
|
||||
]
|
||||
})
|
||||
const groupBookingStatusValue = computed(() => {
|
||||
const field = groupBookingStatusField.value
|
||||
const value = field ? readV4FieldValue(field, props.modelValue) : finalValues.value.group_booking_status
|
||||
return typeof value === 'string' ? value : stringifyRoomInformationValue(value)
|
||||
})
|
||||
const groupBookingStatusLabel = computed(() => {
|
||||
const value = groupBookingStatusValue.value
|
||||
const matchedOption = groupBookingStatusOptions.value.find((option) => option.value === value)
|
||||
return matchedOption?.label ?? formatRoomInformationValue('group_booking_status', value, finalValues.value)
|
||||
})
|
||||
const showGroupBookingStatusSlot = computed(() =>
|
||||
Boolean(groupBookingStatusField.value) || hasValue(finalValues.value.group_booking_status),
|
||||
)
|
||||
const finalDisplayFields = computed(() => buildFinalDisplayFields())
|
||||
const finalScalarEntries = computed(() => scalarEntries(finalValues.value, {
|
||||
hideEditableBreakfast: false,
|
||||
includeIdentifierFallbacks: true,
|
||||
}))
|
||||
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
|
||||
const showCurrentValues = computed(() =>
|
||||
['UPDATE_BOOKING', 'CANCEL_BOOKING'].includes(eventType.value) &&
|
||||
@@ -299,11 +406,7 @@ const showProposedValues = computed(() =>
|
||||
eventType.value === 'UPDATE_BOOKING' &&
|
||||
hasVisibleValues(proposedValues.value, { hideEditableBreakfast: false }),
|
||||
)
|
||||
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && visibleFields.value.length > 0)
|
||||
const showReadonlyBreakfast = computed(() =>
|
||||
hasValue(finalValues.value.breakfast_included) &&
|
||||
(bookingType.value === 'GROUP' || !hasEditableField('/room_information/final_values/breakfast_included')),
|
||||
)
|
||||
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && finalDisplayFields.value.length > 0)
|
||||
|
||||
const scalarFieldOrder = [
|
||||
'group_block_name',
|
||||
@@ -312,19 +415,31 @@ const scalarFieldOrder = [
|
||||
'departure_date',
|
||||
'nights',
|
||||
'rate_code',
|
||||
'breakfast_included',
|
||||
'group_booking_status',
|
||||
'block_id',
|
||||
'confirmation_number',
|
||||
'block_id',
|
||||
'breakfast_included',
|
||||
]
|
||||
const displayFieldOrder = [
|
||||
'group_block_name',
|
||||
'fit_name',
|
||||
'arrival_date',
|
||||
'departure_date',
|
||||
'nights',
|
||||
'rate_code',
|
||||
'confirmation_number',
|
||||
'block_id',
|
||||
'room_items',
|
||||
'breakfast_included',
|
||||
]
|
||||
|
||||
function scalarEntries(
|
||||
values: ReservationV4RoomInformationValues,
|
||||
options: { hideEditableBreakfast?: boolean } = {},
|
||||
options: { hideEditableBreakfast?: boolean; includeIdentifierFallbacks?: boolean } = {},
|
||||
): Array<{ key: string; value: string }> {
|
||||
const hideEditableBreakfast = options.hideEditableBreakfast ?? true
|
||||
const includeIdentifierFallbacks = options.includeIdentifierFallbacks ?? false
|
||||
return scalarFieldOrder
|
||||
.filter((key) => key in values && hasValue(values[key]))
|
||||
.filter((key) => hasValue(values[key]) || (includeIdentifierFallbacks && isRequiredDisplayIdentifier(key)))
|
||||
.filter((key) => key !== 'group_booking_status_label')
|
||||
.filter((key) =>
|
||||
key !== 'breakfast_included' ||
|
||||
@@ -333,7 +448,7 @@ function scalarEntries(
|
||||
)
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: formatRoomInformationValue(key, values[key], values),
|
||||
value: hasValue(values[key]) ? formatRoomInformationValue(key, values[key], values) : '-',
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -344,12 +459,15 @@ function roomItems(values: ReservationV4RoomInformationValues): ReservationRecor
|
||||
}
|
||||
|
||||
function hasEditableField(pointer: string): boolean {
|
||||
return visibleFields.value.some((field) => reservationV4FieldKey(field) === pointer)
|
||||
return roomInformationFields.value.some((field) =>
|
||||
reservationV4FieldKey(field) === pointer && isReservationV4EditableField(field),
|
||||
)
|
||||
}
|
||||
|
||||
function isVisibleRoomInformationField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
|
||||
const key = reservationV4FieldKey(field)
|
||||
return !(bookingType.value === 'GROUP' && key === '/room_information/final_values/breakfast_included')
|
||||
const key = normalizeFieldKey(reservationV4FieldKey(field))
|
||||
return !(bookingType.value === 'GROUP' && key === '/room_information/final_values/breakfast_included') &&
|
||||
isVisibleRoomInformationChangeField(key)
|
||||
}
|
||||
|
||||
function isVisibleRoomInformationChangeField(field: unknown): boolean {
|
||||
@@ -371,6 +489,103 @@ function hasVisibleValues(
|
||||
return scalarEntries(values, options).length > 0 || roomItems(values).length > 0
|
||||
}
|
||||
|
||||
function buildFinalDisplayFields(): ReservationV4TaskCardFieldResult[] {
|
||||
const sourceFields = roomInformationFields.value.filter((field) =>
|
||||
!isHeaderStatusField(field) &&
|
||||
!isReadOnlyFinalPresentationField(field) &&
|
||||
!(bookingType.value === 'GROUP' && roomInformationFieldKey(field) === 'breakfast_included'),
|
||||
)
|
||||
const usedFields = new Set<ReservationV4TaskCardFieldResult>()
|
||||
const result = displayFieldOrder.flatMap((key) => {
|
||||
if (key === 'nights') {
|
||||
return displayReadonlyFieldIfAvailable(key, finalValues.value.nights)
|
||||
}
|
||||
if (isRequiredDisplayIdentifier(key)) {
|
||||
return [createReadonlyDisplayField(key, hasValue(finalValues.value[key]) ? finalValues.value[key] : '-')]
|
||||
}
|
||||
if (key === 'breakfast_included' && bookingType.value === 'GROUP') {
|
||||
return displayReadonlyFieldIfAvailable(key, finalValues.value.breakfast_included, 'CHECKBOX')
|
||||
}
|
||||
const matchedFields = sourceFields.filter((field) => fieldMatchesDisplayKey(field, key))
|
||||
matchedFields.forEach((field) => usedFields.add(field))
|
||||
return matchedFields
|
||||
})
|
||||
const remainingFields = sourceFields.filter((field) => !usedFields.has(field))
|
||||
return [...result, ...remainingFields]
|
||||
}
|
||||
|
||||
function displayReadonlyFieldIfAvailable(
|
||||
key: string,
|
||||
value: unknown,
|
||||
controlType: string = 'READONLY',
|
||||
): ReservationV4TaskCardFieldResult[] {
|
||||
return hasValue(value) ? [createReadonlyDisplayField(key, value, controlType)] : []
|
||||
}
|
||||
|
||||
function createReadonlyDisplayField(
|
||||
key: string,
|
||||
value: unknown,
|
||||
controlType: string = 'READONLY',
|
||||
): ReservationV4TaskCardFieldResult {
|
||||
return {
|
||||
field_path: `room_information.final_values.${key}`,
|
||||
field_pointer: `/room_information/final_values/${key}`,
|
||||
display_name: roomInformationFieldLabel(key),
|
||||
value,
|
||||
editable: false,
|
||||
required: false,
|
||||
control_type: controlType,
|
||||
edit_scope: 'NEVER',
|
||||
write_target: 'NONE',
|
||||
options_source: null,
|
||||
fixed_options: null,
|
||||
raw_readonly: true,
|
||||
validation_errors: [],
|
||||
control_hint: null,
|
||||
}
|
||||
}
|
||||
|
||||
function fieldMatchesDisplayKey(field: ReservationV4TaskCardFieldResult, key: string): boolean {
|
||||
if (key === 'room_items') {
|
||||
return normalizeFieldKey(reservationV4FieldKey(field)).includes('/room_items/')
|
||||
}
|
||||
return roomInformationFieldKey(field) === key
|
||||
}
|
||||
|
||||
function isHeaderStatusField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
return roomInformationFieldKey(field) === 'group_booking_status'
|
||||
}
|
||||
|
||||
function isReadOnlyFinalPresentationField(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
return ['nights', 'confirmation_number', 'block_id'].includes(roomInformationFieldKey(field))
|
||||
}
|
||||
|
||||
function isRequiredDisplayIdentifier(key: string): boolean {
|
||||
return key === 'confirmation_number' || key === 'block_id'
|
||||
}
|
||||
|
||||
function roomInformationFieldKey(field: ReservationV4TaskCardFieldResult): string {
|
||||
const normalizedKey = normalizeFieldKey(reservationV4FieldKey(field))
|
||||
const segments = normalizedKey.split('/').filter(Boolean)
|
||||
return segments[segments.length - 1] ?? ''
|
||||
}
|
||||
|
||||
function normalizeFieldKey(value: string): string {
|
||||
return value.replace(/\./g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
function updateGroupBookingStatus(event: Event): void {
|
||||
const field = groupBookingStatusField.value
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
const target = event.target as HTMLSelectElement
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[reservationV4FieldKey(field)]: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeValues(value: unknown): ReservationV4RoomInformationValues {
|
||||
return isRecord(value) ? value : {}
|
||||
}
|
||||
@@ -402,6 +617,13 @@ function formatRoomInformationValue(
|
||||
)
|
||||
}
|
||||
|
||||
function stringifyRoomInformationValue(value: unknown): string {
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
@@ -442,6 +664,13 @@ const roomInformationLabelKeys: Record<string, string> = {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.room-information__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.room-information__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -457,6 +686,65 @@ const roomInformationLabelKeys: Record<string, string> = {
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
.room-information__status-slot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: min(260px, 100%);
|
||||
}
|
||||
|
||||
.room-information__status-badge,
|
||||
.room-information__status-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.room-information__status-badge span,
|
||||
.room-information__status-field span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.room-information__status-badge sup,
|
||||
.room-information__status-field sup {
|
||||
color: var(--th-color-danger);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.room-information__status-badge strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.room-information__status-field select {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-900);
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.room-information__status-field select:focus {
|
||||
border-color: var(--th-color-blue-600);
|
||||
box-shadow: 0 0 0 3px var(--th-color-info-bg);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.room-information__status-error {
|
||||
color: var(--th-color-danger);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.room-information__panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -534,49 +822,6 @@ const roomInformationLabelKeys: Record<string, string> = {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.room-information__derived {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.room-information__derived-item {
|
||||
display: inline-grid;
|
||||
gap: 4px;
|
||||
min-width: 120px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
padding: 9px 11px;
|
||||
}
|
||||
|
||||
.room-information__derived-item span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.room-information__derived-item strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.room-information__derived-item--checkbox {
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.room-information__derived-item--checkbox span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.room-information__derived-item--checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--th-color-blue-600);
|
||||
}
|
||||
|
||||
.room-information__room-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -621,6 +866,15 @@ const roomInformationLabelKeys: Record<string, string> = {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.room-information__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.room-information__status-slot {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.room-information__grid,
|
||||
.room-information__panel--changes li {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -31,15 +31,16 @@
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
<LocalizedDatePicker
|
||||
v-else-if="isEditable(field) && isDateField(field)"
|
||||
class="v4-field__control"
|
||||
type="date"
|
||||
:model-value="dateFieldValue(field)"
|
||||
:input-id="dateFieldInputId(field)"
|
||||
:name="fieldKey(field)"
|
||||
:aria-invalid="fieldError(field) ? 'true' : 'false'"
|
||||
:value="fieldValue(field)"
|
||||
@input="updateField(field, $event)"
|
||||
>
|
||||
:test-id="fieldKey(field)"
|
||||
:placeholder="t('taskV4.field.datePlaceholder')"
|
||||
:invalid="Boolean(fieldError(field))"
|
||||
@update:model-value="updateFieldValue(field, $event)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else-if="isEditable(field) && isCheckboxField(field)"
|
||||
@@ -120,6 +121,7 @@
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LocalizedDatePicker from '@/components/common/LocalizedDatePicker.vue'
|
||||
import {
|
||||
fetchReservationV4AccountLookups,
|
||||
fetchReservationV4RateCodeLookups,
|
||||
@@ -225,6 +227,10 @@ function fieldKey(field: ReservationV4TaskCardFieldResult): string {
|
||||
return reservationV4FieldKey(field)
|
||||
}
|
||||
|
||||
function dateFieldInputId(field: ReservationV4TaskCardFieldResult): string {
|
||||
return `v4-date-${fieldKey(field).replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
||||
}
|
||||
|
||||
function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
const key = reservationV4FieldKey(field)
|
||||
return !props.readOnly &&
|
||||
@@ -296,6 +302,11 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
|
||||
return stringifyV4Value(value)
|
||||
}
|
||||
|
||||
function dateFieldValue(field: ReservationV4TaskCardFieldResult): string {
|
||||
const value = readV4FieldValue(field, props.modelValue)
|
||||
return typeof value === 'string' ? value : stringifyV4Value(value)
|
||||
}
|
||||
|
||||
function staticFieldValue(field: ReservationV4TaskCardFieldResult): string {
|
||||
const value = fieldValue(field)
|
||||
const matchedOption = fieldOptions(field).find((option) => option.value === value)
|
||||
@@ -309,10 +320,14 @@ function checkboxValue(field: ReservationV4TaskCardFieldResult): boolean {
|
||||
|
||||
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
|
||||
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
updateFieldValue(field, target.value)
|
||||
}
|
||||
|
||||
function updateFieldValue(field: ReservationV4TaskCardFieldResult, value: string): void {
|
||||
delete invalidLookupValues[reservationV4FieldKey(field)]
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[reservationV4FieldKey(field)]: target.value,
|
||||
[reservationV4FieldKey(field)]: value,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,6 @@
|
||||
</div>
|
||||
<div class="task-card-section__status-actions">
|
||||
<ReservationStatusBadge :status="card.card_status" />
|
||||
<button
|
||||
v-if="showPrimaryAction"
|
||||
type="button"
|
||||
class="primary-button"
|
||||
:disabled="primaryActionDisabled"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,15 +118,29 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showOrderLink"
|
||||
class="card-secondary-actions"
|
||||
v-if="showOrderLink || showPrimaryAction"
|
||||
class="task-card-section__footer"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="showOrderLink"
|
||||
class="card-order-link"
|
||||
:to="`/reservation/orders/${boundOrderId}`"
|
||||
>
|
||||
{{ t('task.viewOrder') }}
|
||||
</RouterLink>
|
||||
<div
|
||||
v-if="showPrimaryAction"
|
||||
class="task-card-section__footer-actions"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-button"
|
||||
:disabled="primaryActionDisabled"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -327,13 +332,20 @@ function handlePrimaryAction(): void {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.card-secondary-actions {
|
||||
.task-card-section__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.task-card-section__footer-actions {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.review-box {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
@@ -419,14 +431,19 @@ function handlePrimaryAction(): void {
|
||||
}
|
||||
|
||||
.task-card-section__header,
|
||||
.card-secondary-actions {
|
||||
.task-card-section__footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-card-section__status-actions {
|
||||
.task-card-section__status-actions,
|
||||
.task-card-section__footer-actions {
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.task-card-section__footer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -698,6 +698,7 @@ export default {
|
||||
empty: 'Empty',
|
||||
hidden: 'Sensitive content hidden',
|
||||
selectPlaceholder: 'Select',
|
||||
datePlaceholder: 'yyyy-mm-dd',
|
||||
},
|
||||
error: {
|
||||
loadFailed: 'Failed to load the reservation item handling page.',
|
||||
|
||||
@@ -698,6 +698,7 @@ export default {
|
||||
empty: 'ยังไม่กรอก',
|
||||
hidden: 'ซ่อนข้อมูลที่อ่อนไหวแล้ว',
|
||||
selectPlaceholder: 'เลือก',
|
||||
datePlaceholder: 'yyyy-mm-dd',
|
||||
},
|
||||
error: {
|
||||
loadFailed: 'โหลดหน้าจัดการรายการจองไม่สำเร็จ',
|
||||
|
||||
@@ -698,6 +698,7 @@ export default {
|
||||
empty: '未填写',
|
||||
hidden: '敏感内容已隐藏',
|
||||
selectPlaceholder: '请选择',
|
||||
datePlaceholder: '年-月-日',
|
||||
},
|
||||
error: {
|
||||
loadFailed: '订单事项办理页加载失败。',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import type { DOMWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
@@ -37,9 +39,27 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||
|
||||
const service = await import('@/services/reservationService')
|
||||
|
||||
function findFieldByLabel(wrapper: DOMWrapper<Element>, label: string): DOMWrapper<Element> | undefined {
|
||||
return wrapper.findAll('.v4-field').find((field) => field.find('.v4-field__label').text().includes(label))
|
||||
}
|
||||
|
||||
describe('reservation V4 pages', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
vi.mocked(service.ackReservationV4SourceNotification).mockReset()
|
||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockReset()
|
||||
vi.mocked(service.fetchReservationV4AccountLookups).mockReset()
|
||||
@@ -110,7 +130,7 @@ describe('reservation V4 pages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps task card layout classes and places the primary card action in the card header', async () => {
|
||||
it('keeps task card layout classes and places the primary card action in the card footer', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
detail.business_cards[0]!.card_type = 'VOUCHER'
|
||||
detail.business_cards[0]!.display_payload = {
|
||||
@@ -129,7 +149,13 @@ describe('reservation V4 pages', () => {
|
||||
expect(wrapper.find('.task-card-section__header').exists()).toBe(true)
|
||||
expect(wrapper.find('.card-meta-grid').exists()).toBe(false)
|
||||
expect(wrapper.find('details.safe-payload').exists()).toBe(false)
|
||||
expect(wrapper.find('.task-card-section__header .primary-button').text()).toContain(zhCN.taskV4.confirmCard)
|
||||
expect(wrapper.find('.task-card-section__header .primary-button').exists()).toBe(false)
|
||||
const firstCard = wrapper.find('.task-card-section')
|
||||
const footer = firstCard.find('.task-card-section__footer')
|
||||
expect(footer.exists()).toBe(true)
|
||||
const cardChildren = Array.from(firstCard.element.children)
|
||||
expect(cardChildren[cardChildren.length - 1]).toBe(footer.element)
|
||||
expect(footer.find('.task-card-section__footer-actions .primary-button').text()).toContain(zhCN.taskV4.confirmCard)
|
||||
expect(wrapper.find('.card-actions .primary-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -743,6 +769,26 @@ describe('reservation V4 pages', () => {
|
||||
value: 'LEGACY',
|
||||
}),
|
||||
]
|
||||
const groupStatusField = detail.business_cards[0]!.fields.find((field) =>
|
||||
field.field_pointer === '/room_information/final_values/group_booking_status',
|
||||
)
|
||||
if (groupStatusField) {
|
||||
groupStatusField.validation_errors = ['请选择团队预订状态']
|
||||
groupStatusField.fixed_options = [
|
||||
{
|
||||
value: 'TEN',
|
||||
label: 'TEN-Tentative',
|
||||
},
|
||||
{
|
||||
value: 'DEF',
|
||||
label: 'DEF-Definite',
|
||||
},
|
||||
{
|
||||
value: 'INQ',
|
||||
label: 'INQ-Inquiry',
|
||||
},
|
||||
]
|
||||
}
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
|
||||
...detail,
|
||||
@@ -765,6 +811,22 @@ describe('reservation V4 pages', () => {
|
||||
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.finalValues)
|
||||
expect(roomCard.text()).toContain('TEN-Tentative')
|
||||
expect(roomCard.text()).toContain('3')
|
||||
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.confirmationNumber)
|
||||
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.blockId)
|
||||
const confirmationField = findFieldByLabel(roomCard, zhCN.taskV4.roomInformation.confirmationNumber)
|
||||
const blockIdField = findFieldByLabel(roomCard, zhCN.taskV4.roomInformation.blockId)
|
||||
expect(confirmationField?.find('.v4-field__static').text()).toBe('-')
|
||||
expect(blockIdField?.find('.v4-field__static').text()).toBe('-')
|
||||
const statusSlot = roomCard.find('.room-information__status-slot')
|
||||
const statusSelect = statusSlot.find('select[name="/room_information/final_values/group_booking_status"]')
|
||||
expect(statusSlot.text()).toContain(zhCN.taskV4.roomInformation.groupBookingStatus)
|
||||
expect(statusSlot.text()).toContain('*')
|
||||
expect(statusSlot.text()).toContain('TEN-Tentative')
|
||||
expect(statusSelect.attributes('aria-invalid')).toBe('true')
|
||||
expect(statusSelect.findAll('option').map((option) => (option.element as HTMLOptionElement).value))
|
||||
.toEqual(['', 'TEN', 'DEF', 'INQ'])
|
||||
expect(roomCard.find('.room-information__status-error').text()).toContain('请选择团队预订状态')
|
||||
expect(roomCard.find('.room-information__derived').exists()).toBe(false)
|
||||
expect(roomCard.text()).not.toContain('target_order')
|
||||
expect(roomCard.text()).not.toContain('Adult')
|
||||
expect(roomCard.text()).not.toContain('Legacy Room Type')
|
||||
@@ -780,10 +842,19 @@ describe('reservation V4 pages', () => {
|
||||
expect((breakfast.element as HTMLInputElement).disabled).toBe(true)
|
||||
expect((roomCard.find('input[name="/room_information/final_values/group_block_name"]').element as HTMLInputElement).value)
|
||||
.toBe('GRP-V4-RI-GROUP-001')
|
||||
expect(roomCard.find('input[name="/room_information/final_values/arrival_date"]').attributes('type')).toBe('text')
|
||||
expect((roomCard.find('input[name="/room_information/final_values/arrival_date"]').element as HTMLInputElement).value)
|
||||
.toBe('2026-07-26')
|
||||
expect(roomCard.find('input[name="/room_information/final_values/departure_date"]').attributes('type')).toBe('text')
|
||||
expect((roomCard.find('input[name="/room_information/final_values/departure_date"]').element as HTMLInputElement).value)
|
||||
.toBe('2026-07-29')
|
||||
const roomInformationText = roomCard.text()
|
||||
expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.departureDate))
|
||||
.toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.nights))
|
||||
expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.nights))
|
||||
.toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.rateCode))
|
||||
expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.blockId))
|
||||
.toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.breakfastIncluded))
|
||||
|
||||
await roomCard.find('input[name="/room_information/final_values/group_block_name"]').setValue('前端修正团队名')
|
||||
await roomCard.find('select[name="/room_information/final_values/group_booking_status"]').setValue('DEF')
|
||||
@@ -1286,9 +1357,10 @@ describe('reservation V4 pages', () => {
|
||||
|
||||
it('keeps the per-card primary action right aligned on narrow screens', () => {
|
||||
expect(taskCardSectionSource).toContain('@media (max-width: 980px)')
|
||||
expect(taskCardSectionSource).toContain('.task-card-section__status-actions')
|
||||
expect(taskCardSectionSource).toContain('align-self: stretch')
|
||||
expect(taskCardSectionSource).toContain('.task-card-section__footer-actions')
|
||||
expect(taskCardSectionSource).toContain('padding: 0 20px 20px')
|
||||
expect(taskCardSectionSource).toContain('justify-content: flex-end')
|
||||
expect(taskCardSectionSource).toContain('.task-card-section__footer {\n align-items: flex-end;\n }')
|
||||
})
|
||||
|
||||
it('requires confirmed order id before resolving an unresolved V4 review card', async () => {
|
||||
@@ -1405,7 +1477,7 @@ async function mountWithPlugins(component: object, initialPath: string) {
|
||||
|
||||
return mount(component, {
|
||||
global: {
|
||||
plugins: [pinia, i18n, router],
|
||||
plugins: [pinia, i18n, router, PrimeVue],
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
|
||||
130
client/src/utils/localizedDatePicker.ts
Normal file
130
client/src/utils/localizedDatePicker.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { PrimeVueLocaleOptions } from 'primevue/config'
|
||||
|
||||
export type SupportedDatePickerLocale = 'zh-CN' | 'en-US' | 'th-TH'
|
||||
|
||||
export type LocalizedDatePickerLocaleOptions = Pick<
|
||||
PrimeVueLocaleOptions,
|
||||
'fileSizeTypes' | 'dayNames' | 'dayNamesShort' | 'dayNamesMin' | 'monthNames' | 'monthNamesShort'
|
||||
> &
|
||||
Partial<PrimeVueLocaleOptions>
|
||||
|
||||
export const primeVueDatePickerLocales: Record<SupportedDatePickerLocale, LocalizedDatePickerLocaleOptions> = {
|
||||
'zh-CN': {
|
||||
fileSizeTypes: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
dayNames: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
|
||||
dayNamesShort: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
|
||||
dayNamesMin: ['日', '一', '二', '三', '四', '五', '六'],
|
||||
monthNames: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
|
||||
monthNamesShort: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
||||
chooseDate: '选择日期',
|
||||
chooseMonth: '选择月份',
|
||||
chooseYear: '选择年份',
|
||||
prevMonth: '上个月',
|
||||
nextMonth: '下个月',
|
||||
prevYear: '上一年',
|
||||
nextYear: '下一年',
|
||||
today: '今天',
|
||||
clear: '清除',
|
||||
firstDayOfWeek: 1,
|
||||
showMonthAfterYear: true,
|
||||
dateFormat: 'yy-mm-dd',
|
||||
},
|
||||
'en-US': {
|
||||
fileSizeTypes: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
dayNamesMin: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
monthNames: [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
],
|
||||
monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||
chooseDate: 'Choose date',
|
||||
chooseMonth: 'Choose month',
|
||||
chooseYear: 'Choose year',
|
||||
prevMonth: 'Previous month',
|
||||
nextMonth: 'Next month',
|
||||
prevYear: 'Previous year',
|
||||
nextYear: 'Next year',
|
||||
today: 'Today',
|
||||
clear: 'Clear',
|
||||
firstDayOfWeek: 0,
|
||||
showMonthAfterYear: false,
|
||||
dateFormat: 'yy-mm-dd',
|
||||
},
|
||||
'th-TH': {
|
||||
fileSizeTypes: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
dayNames: ['วันอาทิตย์', 'วันจันทร์', 'วันอังคาร', 'วันพุธ', 'วันพฤหัสบดี', 'วันศุกร์', 'วันเสาร์'],
|
||||
dayNamesShort: ['อา.', 'จ.', 'อ.', 'พ.', 'พฤ.', 'ศ.', 'ส.'],
|
||||
dayNamesMin: ['อา.', 'จ.', 'อ.', 'พ.', 'พฤ.', 'ศ.', 'ส.'],
|
||||
monthNames: [
|
||||
'มกราคม',
|
||||
'กุมภาพันธ์',
|
||||
'มีนาคม',
|
||||
'เมษายน',
|
||||
'พฤษภาคม',
|
||||
'มิถุนายน',
|
||||
'กรกฎาคม',
|
||||
'สิงหาคม',
|
||||
'กันยายน',
|
||||
'ตุลาคม',
|
||||
'พฤศจิกายน',
|
||||
'ธันวาคม',
|
||||
],
|
||||
monthNamesShort: ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'],
|
||||
chooseDate: 'เลือกวันที่',
|
||||
chooseMonth: 'เลือกเดือน',
|
||||
chooseYear: 'เลือกปี',
|
||||
prevMonth: 'เดือนก่อนหน้า',
|
||||
nextMonth: 'เดือนถัดไป',
|
||||
prevYear: 'ปีก่อนหน้า',
|
||||
nextYear: 'ปีถัดไป',
|
||||
today: 'วันนี้',
|
||||
clear: 'ล้าง',
|
||||
firstDayOfWeek: 1,
|
||||
showMonthAfterYear: false,
|
||||
dateFormat: 'yy-mm-dd',
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeDatePickerLocale(nextLocale: string): SupportedDatePickerLocale {
|
||||
if (nextLocale === 'en-US' || nextLocale === 'th-TH') {
|
||||
return nextLocale
|
||||
}
|
||||
return 'zh-CN'
|
||||
}
|
||||
|
||||
export function parseDatePickerValue(value: string): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
return null
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
export function formatDatePickerValue(value: Date | null | undefined): string {
|
||||
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
@@ -201,20 +201,14 @@
|
||||
>Booking Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<DatePicker
|
||||
v-model="documentBookingDateModel"
|
||||
<LocalizedDatePicker
|
||||
v-model="form.document.booking_date"
|
||||
input-id="invoice-booking-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="document-booking-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
test-id="document-booking-date"
|
||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||
:input-class="validationClass('booking-date')"
|
||||
:pt="datePickerPt('document-booking-date', 'booking-date')"
|
||||
:invalid="Boolean(fieldError('booking-date'))"
|
||||
show-icon
|
||||
icon-display="input"
|
||||
append-to="body"
|
||||
panel-class="manual-invoice-datepicker-panel"
|
||||
:described-by="fieldError('booking-date') ? fieldErrorId('booking-date') : undefined"
|
||||
required
|
||||
@update:model-value="markDocumentDatesEdited"
|
||||
/>
|
||||
@@ -274,20 +268,14 @@
|
||||
>Arrival Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<DatePicker
|
||||
v-model="bookingArrivalDateModel"
|
||||
<LocalizedDatePicker
|
||||
v-model="form.booking.arrival_date"
|
||||
input-id="invoice-arrival-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="booking-arrival-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
test-id="booking-arrival-date"
|
||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||
:input-class="validationClass('arrival-date')"
|
||||
:pt="datePickerPt('booking-arrival-date', 'arrival-date')"
|
||||
:invalid="Boolean(fieldError('arrival-date'))"
|
||||
show-icon
|
||||
icon-display="input"
|
||||
append-to="body"
|
||||
panel-class="manual-invoice-datepicker-panel"
|
||||
:described-by="fieldError('arrival-date') ? fieldErrorId('arrival-date') : undefined"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -309,20 +297,14 @@
|
||||
>Departure Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<DatePicker
|
||||
v-model="bookingDepartureDateModel"
|
||||
<LocalizedDatePicker
|
||||
v-model="form.booking.departure_date"
|
||||
input-id="invoice-departure-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="booking-departure-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
test-id="booking-departure-date"
|
||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||
:input-class="validationClass('departure-date')"
|
||||
:pt="datePickerPt('booking-departure-date', 'departure-date')"
|
||||
:invalid="Boolean(fieldError('departure-date'))"
|
||||
show-icon
|
||||
icon-display="input"
|
||||
append-to="body"
|
||||
panel-class="manual-invoice-datepicker-panel"
|
||||
:described-by="fieldError('departure-date') ? fieldErrorId('departure-date') : undefined"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -344,20 +326,14 @@
|
||||
>Due Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<DatePicker
|
||||
v-model="documentDueDateModel"
|
||||
<LocalizedDatePicker
|
||||
v-model="form.document.due_date"
|
||||
input-id="invoice-due-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="document-due-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
test-id="document-due-date"
|
||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||
:input-class="validationClass('due-date')"
|
||||
:pt="datePickerPt('document-due-date', 'due-date')"
|
||||
:invalid="Boolean(fieldError('due-date'))"
|
||||
show-icon
|
||||
icon-display="input"
|
||||
append-to="body"
|
||||
panel-class="manual-invoice-datepicker-panel"
|
||||
:described-by="fieldError('due-date') ? fieldErrorId('due-date') : undefined"
|
||||
required
|
||||
@update:model-value="markDocumentDatesEdited"
|
||||
/>
|
||||
@@ -812,11 +788,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
import { usePrimeVue, type PrimeVueLocaleOptions } from 'primevue/config'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LocalizedDatePicker from '@/components/common/LocalizedDatePicker.vue'
|
||||
import { reservationTimeZone } from '@/config/reservationConfig'
|
||||
import { ApiError } from '@/services/httpClient'
|
||||
import { generateManualReservationInvoice } from '@/services/reservationService'
|
||||
@@ -833,13 +808,6 @@ const MANUAL_SELECTION = 'MANUAL'
|
||||
const VAT_RATE = 0.07
|
||||
const previewCurrency = 'THB'
|
||||
|
||||
type ManualInvoiceDateLocale = 'zh-CN' | 'en-US' | 'th-TH'
|
||||
type ManualInvoiceDatePickerLocale = Pick<
|
||||
PrimeVueLocaleOptions,
|
||||
'fileSizeTypes' | 'dayNames' | 'dayNamesShort' | 'dayNamesMin' | 'monthNames' | 'monthNamesShort'
|
||||
> &
|
||||
Partial<PrimeVueLocaleOptions>
|
||||
|
||||
interface RecipientContactSeed {
|
||||
contact_id: string
|
||||
attention: string
|
||||
@@ -956,7 +924,6 @@ const recipientCompanies: RecipientCompanySeed[] = [
|
||||
]
|
||||
|
||||
const { t, te, locale } = useI18n()
|
||||
const primeVue = usePrimeVue()
|
||||
const authStore = useAuthStore()
|
||||
let nextChargeId = 1
|
||||
const defaultDocumentDates = createDefaultDocumentDates(resolveHotelTimeZone())
|
||||
@@ -1005,122 +972,6 @@ const primaryCharge = computed<ChargeForm>(() => form.charges[0] as ChargeForm)
|
||||
const sourceGroupName = computed(() => form.booking.group_name.trim() || '未填写')
|
||||
const downloadMessage = computed(() => (downloadMessageKey.value ? t(downloadMessageKey.value) : ''))
|
||||
const fieldErrors = computed<FieldErrorMap>(() => (validationAttempted.value ? collectFieldErrors() : {}))
|
||||
const documentBookingDateModel = createDatePickerModel(
|
||||
() => form.document.booking_date,
|
||||
(value) => {
|
||||
form.document.booking_date = value
|
||||
markDocumentDatesEdited()
|
||||
},
|
||||
)
|
||||
const documentDueDateModel = createDatePickerModel(
|
||||
() => form.document.due_date,
|
||||
(value) => {
|
||||
form.document.due_date = value
|
||||
markDocumentDatesEdited()
|
||||
},
|
||||
)
|
||||
const bookingArrivalDateModel = createDatePickerModel(
|
||||
() => form.booking.arrival_date,
|
||||
(value) => {
|
||||
form.booking.arrival_date = value
|
||||
},
|
||||
)
|
||||
const bookingDepartureDateModel = createDatePickerModel(
|
||||
() => form.booking.departure_date,
|
||||
(value) => {
|
||||
form.booking.departure_date = value
|
||||
},
|
||||
)
|
||||
|
||||
const primeVueDatePickerLocales: Record<ManualInvoiceDateLocale, ManualInvoiceDatePickerLocale> = {
|
||||
'zh-CN': {
|
||||
fileSizeTypes: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
dayNames: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
|
||||
dayNamesShort: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
|
||||
dayNamesMin: ['日', '一', '二', '三', '四', '五', '六'],
|
||||
monthNames: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
|
||||
monthNamesShort: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
||||
chooseDate: '选择日期',
|
||||
chooseMonth: '选择月份',
|
||||
chooseYear: '选择年份',
|
||||
prevMonth: '上个月',
|
||||
nextMonth: '下个月',
|
||||
prevYear: '上一年',
|
||||
nextYear: '下一年',
|
||||
today: '今天',
|
||||
clear: '清除',
|
||||
firstDayOfWeek: 1,
|
||||
showMonthAfterYear: true,
|
||||
dateFormat: 'yy-mm-dd',
|
||||
},
|
||||
'en-US': {
|
||||
fileSizeTypes: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
|
||||
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
dayNamesMin: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
monthNames: [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
],
|
||||
monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||
chooseDate: 'Choose date',
|
||||
chooseMonth: 'Choose month',
|
||||
chooseYear: 'Choose year',
|
||||
prevMonth: 'Previous month',
|
||||
nextMonth: 'Next month',
|
||||
prevYear: 'Previous year',
|
||||
nextYear: 'Next year',
|
||||
today: 'Today',
|
||||
clear: 'Clear',
|
||||
firstDayOfWeek: 0,
|
||||
showMonthAfterYear: false,
|
||||
dateFormat: 'yy-mm-dd',
|
||||
},
|
||||
'th-TH': {
|
||||
fileSizeTypes: ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
dayNames: ['วันอาทิตย์', 'วันจันทร์', 'วันอังคาร', 'วันพุธ', 'วันพฤหัสบดี', 'วันศุกร์', 'วันเสาร์'],
|
||||
dayNamesShort: ['อา.', 'จ.', 'อ.', 'พ.', 'พฤ.', 'ศ.', 'ส.'],
|
||||
dayNamesMin: ['อา.', 'จ.', 'อ.', 'พ.', 'พฤ.', 'ศ.', 'ส.'],
|
||||
monthNames: [
|
||||
'มกราคม',
|
||||
'กุมภาพันธ์',
|
||||
'มีนาคม',
|
||||
'เมษายน',
|
||||
'พฤษภาคม',
|
||||
'มิถุนายน',
|
||||
'กรกฎาคม',
|
||||
'สิงหาคม',
|
||||
'กันยายน',
|
||||
'ตุลาคม',
|
||||
'พฤศจิกายน',
|
||||
'ธันวาคม',
|
||||
],
|
||||
monthNamesShort: ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'],
|
||||
chooseDate: 'เลือกวันที่',
|
||||
chooseMonth: 'เลือกเดือน',
|
||||
chooseYear: 'เลือกปี',
|
||||
prevMonth: 'เดือนก่อนหน้า',
|
||||
nextMonth: 'เดือนถัดไป',
|
||||
prevYear: 'ปีก่อนหน้า',
|
||||
nextYear: 'ปีถัดไป',
|
||||
today: 'วันนี้',
|
||||
clear: 'ล้าง',
|
||||
firstDayOfWeek: 1,
|
||||
showMonthAfterYear: false,
|
||||
dateFormat: 'yy-mm-dd',
|
||||
},
|
||||
}
|
||||
|
||||
const previewTotals = computed(() => {
|
||||
const total = form.charges.reduce((sum, charge) => sum + lineAmount(charge), 0)
|
||||
const subtotal = total / (1 + VAT_RATE)
|
||||
@@ -1139,14 +990,6 @@ const alertTitle = computed(() => {
|
||||
return t('manualInvoice.validationTitle')
|
||||
})
|
||||
|
||||
watch(
|
||||
locale,
|
||||
(nextLocale) => {
|
||||
syncPrimeVueDatePickerLocale(nextLocale)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.recipient.company_code,
|
||||
(companyCode) => {
|
||||
@@ -1466,37 +1309,6 @@ function validationAria(fieldKey: string): Record<string, string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
function datePickerPt(testId: string, fieldKey: string): Record<string, unknown> {
|
||||
return {
|
||||
pcInputText: {
|
||||
root: {
|
||||
'data-testid': testId,
|
||||
lang: locale.value,
|
||||
...validationAria(fieldKey),
|
||||
},
|
||||
},
|
||||
panel: {
|
||||
lang: locale.value,
|
||||
'data-testid': `${testId}-panel`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function syncPrimeVueDatePickerLocale(nextLocale: string): void {
|
||||
const supportedLocale = normalizeDateLocale(nextLocale)
|
||||
primeVue.config.locale = {
|
||||
...(primeVue.config.locale ?? primeVueDatePickerLocales['en-US']),
|
||||
...primeVueDatePickerLocales[supportedLocale],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDateLocale(nextLocale: string): ManualInvoiceDateLocale {
|
||||
if (nextLocale === 'en-US' || nextLocale === 'th-TH') {
|
||||
return nextLocale
|
||||
}
|
||||
return 'zh-CN'
|
||||
}
|
||||
|
||||
function chargeFieldKey(index: number, fieldName: ChargeFieldName): string {
|
||||
return `charge-${index}-${fieldName}`
|
||||
}
|
||||
@@ -1688,40 +1500,6 @@ function datePartsToInputValue(parts: Intl.DateTimeFormatPart[]): string {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function createDatePickerModel(readValue: () => string, writeValue: (value: string) => void) {
|
||||
return computed<Date | null>({
|
||||
get: () => parseInputDateValue(readValue()),
|
||||
set: (value) => {
|
||||
writeValue(formatDatePickerValue(value))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function parseInputDateValue(readValue: string): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(readValue)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
return null
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
function formatDatePickerValue(value: Date | null | undefined): string {
|
||||
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -1962,21 +1740,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.localized-date-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.localized-date-picker :deep(.p-inputtext) {
|
||||
width: 100%;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.localized-date-picker :deep(.p-datepicker-input-icon-container) {
|
||||
right: 12px;
|
||||
color: var(--th-color-navy-950);
|
||||
}
|
||||
|
||||
.number-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -2414,11 +2177,3 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.manual-invoice-datepicker-panel {
|
||||
z-index: 1200;
|
||||
color: var(--th-color-navy-950);
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user