统一预订事项日期选择控件
This commit is contained in:
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>
|
||||||
@@ -31,15 +31,16 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<LocalizedDatePicker
|
||||||
v-else-if="isEditable(field) && isDateField(field)"
|
v-else-if="isEditable(field) && isDateField(field)"
|
||||||
class="v4-field__control"
|
:model-value="dateFieldValue(field)"
|
||||||
type="date"
|
:input-id="dateFieldInputId(field)"
|
||||||
:name="fieldKey(field)"
|
:name="fieldKey(field)"
|
||||||
:aria-invalid="fieldError(field) ? 'true' : 'false'"
|
:test-id="fieldKey(field)"
|
||||||
:value="fieldValue(field)"
|
:placeholder="t('taskV4.field.datePlaceholder')"
|
||||||
@input="updateField(field, $event)"
|
:invalid="Boolean(fieldError(field))"
|
||||||
>
|
@update:model-value="updateFieldValue(field, $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
v-else-if="isEditable(field) && isCheckboxField(field)"
|
v-else-if="isEditable(field) && isCheckboxField(field)"
|
||||||
@@ -120,6 +121,7 @@
|
|||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import LocalizedDatePicker from '@/components/common/LocalizedDatePicker.vue'
|
||||||
import {
|
import {
|
||||||
fetchReservationV4AccountLookups,
|
fetchReservationV4AccountLookups,
|
||||||
fetchReservationV4RateCodeLookups,
|
fetchReservationV4RateCodeLookups,
|
||||||
@@ -225,6 +227,10 @@ function fieldKey(field: ReservationV4TaskCardFieldResult): string {
|
|||||||
return reservationV4FieldKey(field)
|
return reservationV4FieldKey(field)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dateFieldInputId(field: ReservationV4TaskCardFieldResult): string {
|
||||||
|
return `v4-date-${fieldKey(field).replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
||||||
|
}
|
||||||
|
|
||||||
function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
|
function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
|
||||||
const key = reservationV4FieldKey(field)
|
const key = reservationV4FieldKey(field)
|
||||||
return !props.readOnly &&
|
return !props.readOnly &&
|
||||||
@@ -296,6 +302,11 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
|
|||||||
return stringifyV4Value(value)
|
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 {
|
function staticFieldValue(field: ReservationV4TaskCardFieldResult): string {
|
||||||
const value = fieldValue(field)
|
const value = fieldValue(field)
|
||||||
const matchedOption = fieldOptions(field).find((option) => option.value === value)
|
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 {
|
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
|
||||||
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||||
|
updateFieldValue(field, target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFieldValue(field: ReservationV4TaskCardFieldResult, value: string): void {
|
||||||
delete invalidLookupValues[reservationV4FieldKey(field)]
|
delete invalidLookupValues[reservationV4FieldKey(field)]
|
||||||
emit('update:modelValue', {
|
emit('update:modelValue', {
|
||||||
...props.modelValue,
|
...props.modelValue,
|
||||||
[reservationV4FieldKey(field)]: target.value,
|
[reservationV4FieldKey(field)]: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -698,6 +698,7 @@ export default {
|
|||||||
empty: 'Empty',
|
empty: 'Empty',
|
||||||
hidden: 'Sensitive content hidden',
|
hidden: 'Sensitive content hidden',
|
||||||
selectPlaceholder: 'Select',
|
selectPlaceholder: 'Select',
|
||||||
|
datePlaceholder: 'yyyy-mm-dd',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
loadFailed: 'Failed to load the reservation item handling page.',
|
loadFailed: 'Failed to load the reservation item handling page.',
|
||||||
|
|||||||
@@ -698,6 +698,7 @@ export default {
|
|||||||
empty: 'ยังไม่กรอก',
|
empty: 'ยังไม่กรอก',
|
||||||
hidden: 'ซ่อนข้อมูลที่อ่อนไหวแล้ว',
|
hidden: 'ซ่อนข้อมูลที่อ่อนไหวแล้ว',
|
||||||
selectPlaceholder: 'เลือก',
|
selectPlaceholder: 'เลือก',
|
||||||
|
datePlaceholder: 'yyyy-mm-dd',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
loadFailed: 'โหลดหน้าจัดการรายการจองไม่สำเร็จ',
|
loadFailed: 'โหลดหน้าจัดการรายการจองไม่สำเร็จ',
|
||||||
|
|||||||
@@ -698,6 +698,7 @@ export default {
|
|||||||
empty: '未填写',
|
empty: '未填写',
|
||||||
hidden: '敏感内容已隐藏',
|
hidden: '敏感内容已隐藏',
|
||||||
selectPlaceholder: '请选择',
|
selectPlaceholder: '请选择',
|
||||||
|
datePlaceholder: '年-月-日',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
loadFailed: '订单事项办理页加载失败。',
|
loadFailed: '订单事项办理页加载失败。',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
@@ -40,6 +41,20 @@ const service = await import('@/services/reservationService')
|
|||||||
describe('reservation V4 pages', () => {
|
describe('reservation V4 pages', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionStorage.clear()
|
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.ackReservationV4SourceNotification).mockReset()
|
||||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockReset()
|
vi.mocked(service.confirmReservationV4OrderTaskCard).mockReset()
|
||||||
vi.mocked(service.fetchReservationV4AccountLookups).mockReset()
|
vi.mocked(service.fetchReservationV4AccountLookups).mockReset()
|
||||||
@@ -780,8 +795,10 @@ describe('reservation V4 pages', () => {
|
|||||||
expect((breakfast.element as HTMLInputElement).disabled).toBe(true)
|
expect((breakfast.element as HTMLInputElement).disabled).toBe(true)
|
||||||
expect((roomCard.find('input[name="/room_information/final_values/group_block_name"]').element as HTMLInputElement).value)
|
expect((roomCard.find('input[name="/room_information/final_values/group_block_name"]').element as HTMLInputElement).value)
|
||||||
.toBe('GRP-V4-RI-GROUP-001')
|
.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)
|
expect((roomCard.find('input[name="/room_information/final_values/arrival_date"]').element as HTMLInputElement).value)
|
||||||
.toBe('2026-07-26')
|
.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)
|
expect((roomCard.find('input[name="/room_information/final_values/departure_date"]').element as HTMLInputElement).value)
|
||||||
.toBe('2026-07-29')
|
.toBe('2026-07-29')
|
||||||
|
|
||||||
@@ -1405,7 +1422,7 @@ async function mountWithPlugins(component: object, initialPath: string) {
|
|||||||
|
|
||||||
return mount(component, {
|
return mount(component, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [pinia, i18n, router],
|
plugins: [pinia, i18n, router, PrimeVue],
|
||||||
stubs: {
|
stubs: {
|
||||||
RouterLink: {
|
RouterLink: {
|
||||||
template: '<a><slot /></a>',
|
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>
|
>Booking Date</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="date-control">
|
<div class="date-control">
|
||||||
<DatePicker
|
<LocalizedDatePicker
|
||||||
v-model="documentBookingDateModel"
|
v-model="form.document.booking_date"
|
||||||
input-id="invoice-booking-date"
|
input-id="invoice-booking-date"
|
||||||
class="localized-date-picker"
|
test-id="document-booking-date"
|
||||||
data-testid="document-booking-date-picker"
|
|
||||||
date-format="yy-mm-dd"
|
|
||||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||||
:input-class="validationClass('booking-date')"
|
:input-class="validationClass('booking-date')"
|
||||||
:pt="datePickerPt('document-booking-date', 'booking-date')"
|
|
||||||
:invalid="Boolean(fieldError('booking-date'))"
|
:invalid="Boolean(fieldError('booking-date'))"
|
||||||
show-icon
|
:described-by="fieldError('booking-date') ? fieldErrorId('booking-date') : undefined"
|
||||||
icon-display="input"
|
|
||||||
append-to="body"
|
|
||||||
panel-class="manual-invoice-datepicker-panel"
|
|
||||||
required
|
required
|
||||||
@update:model-value="markDocumentDatesEdited"
|
@update:model-value="markDocumentDatesEdited"
|
||||||
/>
|
/>
|
||||||
@@ -274,20 +268,14 @@
|
|||||||
>Arrival Date</label>
|
>Arrival Date</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="date-control">
|
<div class="date-control">
|
||||||
<DatePicker
|
<LocalizedDatePicker
|
||||||
v-model="bookingArrivalDateModel"
|
v-model="form.booking.arrival_date"
|
||||||
input-id="invoice-arrival-date"
|
input-id="invoice-arrival-date"
|
||||||
class="localized-date-picker"
|
test-id="booking-arrival-date"
|
||||||
data-testid="booking-arrival-date-picker"
|
|
||||||
date-format="yy-mm-dd"
|
|
||||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||||
:input-class="validationClass('arrival-date')"
|
:input-class="validationClass('arrival-date')"
|
||||||
:pt="datePickerPt('booking-arrival-date', 'arrival-date')"
|
|
||||||
:invalid="Boolean(fieldError('arrival-date'))"
|
:invalid="Boolean(fieldError('arrival-date'))"
|
||||||
show-icon
|
:described-by="fieldError('arrival-date') ? fieldErrorId('arrival-date') : undefined"
|
||||||
icon-display="input"
|
|
||||||
append-to="body"
|
|
||||||
panel-class="manual-invoice-datepicker-panel"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,20 +297,14 @@
|
|||||||
>Departure Date</label>
|
>Departure Date</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="date-control">
|
<div class="date-control">
|
||||||
<DatePicker
|
<LocalizedDatePicker
|
||||||
v-model="bookingDepartureDateModel"
|
v-model="form.booking.departure_date"
|
||||||
input-id="invoice-departure-date"
|
input-id="invoice-departure-date"
|
||||||
class="localized-date-picker"
|
test-id="booking-departure-date"
|
||||||
data-testid="booking-departure-date-picker"
|
|
||||||
date-format="yy-mm-dd"
|
|
||||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||||
:input-class="validationClass('departure-date')"
|
:input-class="validationClass('departure-date')"
|
||||||
:pt="datePickerPt('booking-departure-date', 'departure-date')"
|
|
||||||
:invalid="Boolean(fieldError('departure-date'))"
|
:invalid="Boolean(fieldError('departure-date'))"
|
||||||
show-icon
|
:described-by="fieldError('departure-date') ? fieldErrorId('departure-date') : undefined"
|
||||||
icon-display="input"
|
|
||||||
append-to="body"
|
|
||||||
panel-class="manual-invoice-datepicker-panel"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,20 +326,14 @@
|
|||||||
>Due Date</label>
|
>Due Date</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="date-control">
|
<div class="date-control">
|
||||||
<DatePicker
|
<LocalizedDatePicker
|
||||||
v-model="documentDueDateModel"
|
v-model="form.document.due_date"
|
||||||
input-id="invoice-due-date"
|
input-id="invoice-due-date"
|
||||||
class="localized-date-picker"
|
test-id="document-due-date"
|
||||||
data-testid="document-due-date-picker"
|
|
||||||
date-format="yy-mm-dd"
|
|
||||||
:placeholder="t('manualInvoice.datePlaceholder')"
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
||||||
:input-class="validationClass('due-date')"
|
:input-class="validationClass('due-date')"
|
||||||
:pt="datePickerPt('document-due-date', 'due-date')"
|
|
||||||
:invalid="Boolean(fieldError('due-date'))"
|
:invalid="Boolean(fieldError('due-date'))"
|
||||||
show-icon
|
:described-by="fieldError('due-date') ? fieldErrorId('due-date') : undefined"
|
||||||
icon-display="input"
|
|
||||||
append-to="body"
|
|
||||||
panel-class="manual-invoice-datepicker-panel"
|
|
||||||
required
|
required
|
||||||
@update:model-value="markDocumentDatesEdited"
|
@update:model-value="markDocumentDatesEdited"
|
||||||
/>
|
/>
|
||||||
@@ -812,11 +788,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DatePicker from 'primevue/datepicker'
|
|
||||||
import { usePrimeVue, type PrimeVueLocaleOptions } from 'primevue/config'
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import LocalizedDatePicker from '@/components/common/LocalizedDatePicker.vue'
|
||||||
import { reservationTimeZone } from '@/config/reservationConfig'
|
import { reservationTimeZone } from '@/config/reservationConfig'
|
||||||
import { ApiError } from '@/services/httpClient'
|
import { ApiError } from '@/services/httpClient'
|
||||||
import { generateManualReservationInvoice } from '@/services/reservationService'
|
import { generateManualReservationInvoice } from '@/services/reservationService'
|
||||||
@@ -833,13 +808,6 @@ const MANUAL_SELECTION = 'MANUAL'
|
|||||||
const VAT_RATE = 0.07
|
const VAT_RATE = 0.07
|
||||||
const previewCurrency = 'THB'
|
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 {
|
interface RecipientContactSeed {
|
||||||
contact_id: string
|
contact_id: string
|
||||||
attention: string
|
attention: string
|
||||||
@@ -956,7 +924,6 @@ const recipientCompanies: RecipientCompanySeed[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const { t, te, locale } = useI18n()
|
const { t, te, locale } = useI18n()
|
||||||
const primeVue = usePrimeVue()
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
let nextChargeId = 1
|
let nextChargeId = 1
|
||||||
const defaultDocumentDates = createDefaultDocumentDates(resolveHotelTimeZone())
|
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 sourceGroupName = computed(() => form.booking.group_name.trim() || '未填写')
|
||||||
const downloadMessage = computed(() => (downloadMessageKey.value ? t(downloadMessageKey.value) : ''))
|
const downloadMessage = computed(() => (downloadMessageKey.value ? t(downloadMessageKey.value) : ''))
|
||||||
const fieldErrors = computed<FieldErrorMap>(() => (validationAttempted.value ? collectFieldErrors() : {}))
|
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 previewTotals = computed(() => {
|
||||||
const total = form.charges.reduce((sum, charge) => sum + lineAmount(charge), 0)
|
const total = form.charges.reduce((sum, charge) => sum + lineAmount(charge), 0)
|
||||||
const subtotal = total / (1 + VAT_RATE)
|
const subtotal = total / (1 + VAT_RATE)
|
||||||
@@ -1139,14 +990,6 @@ const alertTitle = computed(() => {
|
|||||||
return t('manualInvoice.validationTitle')
|
return t('manualInvoice.validationTitle')
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
locale,
|
|
||||||
(nextLocale) => {
|
|
||||||
syncPrimeVueDatePickerLocale(nextLocale)
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => form.recipient.company_code,
|
() => form.recipient.company_code,
|
||||||
(companyCode) => {
|
(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 {
|
function chargeFieldKey(index: number, fieldName: ChargeFieldName): string {
|
||||||
return `charge-${index}-${fieldName}`
|
return `charge-${index}-${fieldName}`
|
||||||
}
|
}
|
||||||
@@ -1688,40 +1500,6 @@ function datePartsToInputValue(parts: Intl.DateTimeFormatPart[]): string {
|
|||||||
return `${year}-${month}-${day}`
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
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;
|
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 {
|
.number-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -2414,11 +2177,3 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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