优化手工开票日期选择器国际化
This commit is contained in:
@@ -283,6 +283,7 @@ export default {
|
||||
departureDate: 'Departure date',
|
||||
roomRateNote: 'Room rate note',
|
||||
roomRateNotePlaceholder: 'e.g. including BF',
|
||||
datePlaceholder: 'yyyy-mm-dd',
|
||||
extraBedRate: 'Extra bed rate',
|
||||
description: 'Description',
|
||||
roomType: 'Room type',
|
||||
|
||||
@@ -283,6 +283,7 @@ export default {
|
||||
departureDate: 'Departure Date',
|
||||
roomRateNote: 'Room Rate Note',
|
||||
roomRateNotePlaceholder: 'เช่น including BF',
|
||||
datePlaceholder: 'yyyy-mm-dd',
|
||||
extraBedRate: 'Extra Bed Rate',
|
||||
description: 'Description',
|
||||
roomType: 'Room Type',
|
||||
|
||||
@@ -283,6 +283,7 @@ export default {
|
||||
departureDate: '离店日期(Departure Date)',
|
||||
roomRateNote: '房价备注(Room Rate Note)',
|
||||
roomRateNotePlaceholder: '例如 including BF',
|
||||
datePlaceholder: '年-月-日',
|
||||
extraBedRate: '加床价格(Extra Bed Rate)',
|
||||
description: '费用描述(Description)',
|
||||
roomType: '房型(Room Type)',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -89,7 +90,7 @@ function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[]
|
||||
|
||||
const wrapper = mount(ReservationManualInvoiceView, {
|
||||
global: {
|
||||
plugins: [i18n, pinia],
|
||||
plugins: [i18n, pinia, PrimeVue],
|
||||
},
|
||||
})
|
||||
return Object.assign(wrapper, {
|
||||
@@ -120,6 +121,20 @@ describe('ReservationManualInvoiceView', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(service.generateManualReservationInvoice).mockReset()
|
||||
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(),
|
||||
})),
|
||||
})
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:manual-invoice-pdf'),
|
||||
@@ -135,6 +150,7 @@ describe('ReservationManualInvoiceView', () => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
if (nativeCreateObjectURL) {
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
@@ -276,13 +292,26 @@ describe('ReservationManualInvoiceView', () => {
|
||||
expect(wrapper.text()).not.toContain('说明')
|
||||
})
|
||||
|
||||
it('passes current locale to native date inputs', () => {
|
||||
it('uses localized DatePicker controls for date fields', async () => {
|
||||
const wrapper = mountView({ locale: 'th-TH' })
|
||||
const arrivalDateInput = wrapper.find('[data-testid="booking-arrival-date"]')
|
||||
|
||||
expect(wrapper.find('[data-testid="document-booking-date"]').attributes('lang')).toBe('th-TH')
|
||||
expect(wrapper.find('[data-testid="booking-arrival-date"]').attributes('lang')).toBe('th-TH')
|
||||
expect(wrapper.find('[data-testid="booking-departure-date"]').attributes('lang')).toBe('th-TH')
|
||||
expect(wrapper.find('[data-testid="document-due-date"]').attributes('lang')).toBe('th-TH')
|
||||
expect(arrivalDateInput.attributes('type')).toBe('text')
|
||||
expect(arrivalDateInput.attributes('placeholder')).toBe(thTH.manualInvoice.datePlaceholder)
|
||||
expect(arrivalDateInput.attributes('lang')).toBe('th-TH')
|
||||
expect(wrapper.find('[data-testid="document-booking-date"]').attributes('type')).toBe('text')
|
||||
expect(wrapper.find('[data-testid="booking-departure-date"]').attributes('type')).toBe('text')
|
||||
expect(wrapper.find('[data-testid="document-due-date"]').attributes('type')).toBe('text')
|
||||
|
||||
await arrivalDateInput.setValue('2026-07-17')
|
||||
await arrivalDateInput.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const arrivalDatePanel = document.body.querySelector('[data-testid="booking-arrival-date-panel"]')
|
||||
|
||||
expect(wrapper.find('[data-testid="booking-arrival-date-panel"]').exists()).toBe(false)
|
||||
expect(arrivalDatePanel?.textContent).toContain('กรกฎาคม')
|
||||
expect(arrivalDatePanel?.textContent).not.toContain('2026年07月')
|
||||
})
|
||||
|
||||
it('marks required fields and highlights empty inputs after submit', async () => {
|
||||
|
||||
@@ -201,17 +201,23 @@
|
||||
>Booking Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
id="invoice-booking-date"
|
||||
v-model="form.document.booking_date"
|
||||
data-testid="document-booking-date"
|
||||
type="date"
|
||||
:lang="locale"
|
||||
:class="validationClass('booking-date')"
|
||||
v-bind="validationAria('booking-date')"
|
||||
<DatePicker
|
||||
v-model="documentBookingDateModel"
|
||||
input-id="invoice-booking-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="document-booking-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
: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"
|
||||
required
|
||||
@input="markDocumentDatesEdited"
|
||||
>
|
||||
@update:model-value="markDocumentDatesEdited"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('booking-date')"
|
||||
@@ -268,16 +274,22 @@
|
||||
>Arrival Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
id="invoice-arrival-date"
|
||||
v-model="form.booking.arrival_date"
|
||||
data-testid="booking-arrival-date"
|
||||
type="date"
|
||||
:lang="locale"
|
||||
:class="validationClass('arrival-date')"
|
||||
v-bind="validationAria('arrival-date')"
|
||||
<DatePicker
|
||||
v-model="bookingArrivalDateModel"
|
||||
input-id="invoice-arrival-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="booking-arrival-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
: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"
|
||||
required
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('arrival-date')"
|
||||
@@ -297,16 +309,22 @@
|
||||
>Departure Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
id="invoice-departure-date"
|
||||
v-model="form.booking.departure_date"
|
||||
data-testid="booking-departure-date"
|
||||
type="date"
|
||||
:lang="locale"
|
||||
:class="validationClass('departure-date')"
|
||||
v-bind="validationAria('departure-date')"
|
||||
<DatePicker
|
||||
v-model="bookingDepartureDateModel"
|
||||
input-id="invoice-departure-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="booking-departure-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
: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"
|
||||
required
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('departure-date')"
|
||||
@@ -326,17 +344,23 @@
|
||||
>Due Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
id="invoice-due-date"
|
||||
v-model="form.document.due_date"
|
||||
data-testid="document-due-date"
|
||||
type="date"
|
||||
:lang="locale"
|
||||
:class="validationClass('due-date')"
|
||||
v-bind="validationAria('due-date')"
|
||||
<DatePicker
|
||||
v-model="documentDueDateModel"
|
||||
input-id="invoice-due-date"
|
||||
class="localized-date-picker"
|
||||
data-testid="document-due-date-picker"
|
||||
date-format="yy-mm-dd"
|
||||
: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"
|
||||
required
|
||||
@input="markDocumentDatesEdited"
|
||||
>
|
||||
@update:model-value="markDocumentDatesEdited"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('due-date')"
|
||||
@@ -788,6 +812,8 @@
|
||||
</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'
|
||||
|
||||
@@ -807,6 +833,13 @@ 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
|
||||
@@ -923,6 +956,7 @@ const recipientCompanies: RecipientCompanySeed[] = [
|
||||
]
|
||||
|
||||
const { t, te, locale } = useI18n()
|
||||
const primeVue = usePrimeVue()
|
||||
const authStore = useAuthStore()
|
||||
let nextChargeId = 1
|
||||
const defaultDocumentDates = createDefaultDocumentDates(resolveHotelTimeZone())
|
||||
@@ -971,6 +1005,121 @@ 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)
|
||||
@@ -990,6 +1139,14 @@ const alertTitle = computed(() => {
|
||||
return t('manualInvoice.validationTitle')
|
||||
})
|
||||
|
||||
watch(
|
||||
locale,
|
||||
(nextLocale) => {
|
||||
syncPrimeVueDatePickerLocale(nextLocale)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.recipient.company_code,
|
||||
(companyCode) => {
|
||||
@@ -1309,6 +1466,37 @@ 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}`
|
||||
}
|
||||
@@ -1500,6 +1688,40 @@ 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)
|
||||
}
|
||||
@@ -1740,6 +1962,21 @@ 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;
|
||||
@@ -2177,3 +2414,11 @@ 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