Compare commits

...

2 Commits

Author SHA1 Message Date
andy
75d860129c 优化手工开票日期选择器国际化 2026-07-17 17:57:34 +07:00
andy
7930d59359 清理手工开票页面冗余文案 2026-07-17 17:24:19 +07:00
5 changed files with 343 additions and 117 deletions

View File

@@ -283,6 +283,7 @@ export default {
departureDate: 'Departure date', departureDate: 'Departure date',
roomRateNote: 'Room rate note', roomRateNote: 'Room rate note',
roomRateNotePlaceholder: 'e.g. including BF', roomRateNotePlaceholder: 'e.g. including BF',
datePlaceholder: 'yyyy-mm-dd',
extraBedRate: 'Extra bed rate', extraBedRate: 'Extra bed rate',
description: 'Description', description: 'Description',
roomType: 'Room type', roomType: 'Room type',
@@ -295,6 +296,7 @@ export default {
total: 'Total', total: 'Total',
addCharge: 'Add row', addCharge: 'Add row',
removeCharge: 'Remove', removeCharge: 'Remove',
chargeLineLabel: 'Row {index}',
removeChargeLineAria: 'Remove row {index}', removeChargeLineAria: 'Remove row {index}',
maxChargesReached: 'V1 supports up to 10 charge lines.', maxChargesReached: 'V1 supports up to 10 charge lines.',
previewDisclaimer: 'The frontend only previews amounts. Backend totals are authoritative.', previewDisclaimer: 'The frontend only previews amounts. Backend totals are authoritative.',

View File

@@ -283,6 +283,7 @@ export default {
departureDate: 'Departure Date', departureDate: 'Departure Date',
roomRateNote: 'Room Rate Note', roomRateNote: 'Room Rate Note',
roomRateNotePlaceholder: 'เช่น including BF', roomRateNotePlaceholder: 'เช่น including BF',
datePlaceholder: 'yyyy-mm-dd',
extraBedRate: 'Extra Bed Rate', extraBedRate: 'Extra Bed Rate',
description: 'Description', description: 'Description',
roomType: 'Room Type', roomType: 'Room Type',
@@ -295,6 +296,7 @@ export default {
total: 'Total', total: 'Total',
addCharge: 'เพิ่มแถว', addCharge: 'เพิ่มแถว',
removeCharge: 'ลบ', removeCharge: 'ลบ',
chargeLineLabel: 'แถวที่ {index}',
removeChargeLineAria: 'ลบแถวที่ {index}', removeChargeLineAria: 'ลบแถวที่ {index}',
maxChargesReached: 'V1 รองรับได้สูงสุด 10 รายการค่าใช้จ่าย', maxChargesReached: 'V1 รองรับได้สูงสุด 10 รายการค่าใช้จ่าย',
previewDisclaimer: 'ยอดนี้เป็นเพียงตัวอย่าง ยอดสุดท้ายให้ยึด totals จากหลังบ้าน', previewDisclaimer: 'ยอดนี้เป็นเพียงตัวอย่าง ยอดสุดท้ายให้ยึด totals จากหลังบ้าน',

View File

@@ -283,6 +283,7 @@ export default {
departureDate: '离店日期Departure Date', departureDate: '离店日期Departure Date',
roomRateNote: '房价备注Room Rate Note', roomRateNote: '房价备注Room Rate Note',
roomRateNotePlaceholder: '例如 including BF', roomRateNotePlaceholder: '例如 including BF',
datePlaceholder: '年-月-日',
extraBedRate: '加床价格Extra Bed Rate', extraBedRate: '加床价格Extra Bed Rate',
description: '费用描述Description', description: '费用描述Description',
roomType: '房型Room Type', roomType: '房型Room Type',
@@ -295,6 +296,7 @@ export default {
total: 'Total', total: 'Total',
addCharge: '新增一行', addCharge: '新增一行',
removeCharge: '删除', removeCharge: '删除',
chargeLineLabel: '第 {index} 行',
removeChargeLineAria: '删除第 {index} 行', removeChargeLineAria: '删除第 {index} 行',
maxChargesReached: '第一版最多支持 10 条费用明细。', maxChargesReached: '第一版最多支持 10 条费用明细。',
previewDisclaimer: '前端仅做金额预览,最终金额以后端返回 totals 为准。', previewDisclaimer: '前端仅做金额预览,最终金额以后端返回 totals 为准。',

View File

@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@@ -89,7 +90,7 @@ function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[]
const wrapper = mount(ReservationManualInvoiceView, { const wrapper = mount(ReservationManualInvoiceView, {
global: { global: {
plugins: [i18n, pinia], plugins: [i18n, pinia, PrimeVue],
}, },
}) })
return Object.assign(wrapper, { return Object.assign(wrapper, {
@@ -120,6 +121,20 @@ describe('ReservationManualInvoiceView', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(service.generateManualReservationInvoice).mockReset() vi.mocked(service.generateManualReservationInvoice).mockReset()
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(),
})),
})
Object.defineProperty(URL, 'createObjectURL', { Object.defineProperty(URL, 'createObjectURL', {
configurable: true, configurable: true,
value: vi.fn(() => 'blob:manual-invoice-pdf'), value: vi.fn(() => 'blob:manual-invoice-pdf'),
@@ -135,6 +150,7 @@ describe('ReservationManualInvoiceView', () => {
vi.useRealTimers() vi.useRealTimers()
vi.unstubAllGlobals() vi.unstubAllGlobals()
vi.restoreAllMocks() vi.restoreAllMocks()
document.body.innerHTML = ''
if (nativeCreateObjectURL) { if (nativeCreateObjectURL) {
Object.defineProperty(URL, 'createObjectURL', { Object.defineProperty(URL, 'createObjectURL', {
configurable: true, configurable: true,
@@ -258,16 +274,44 @@ describe('ReservationManualInvoiceView', () => {
expect((wrapper.find('[data-testid="booking-room-rate-note"]').element as HTMLInputElement).placeholder).toBe( expect((wrapper.find('[data-testid="booking-room-rate-note"]').element as HTMLInputElement).placeholder).toBe(
'e.g. including BF', 'e.g. including BF',
) )
expect(wrapper.find('.control-caption').exists()).toBe(false)
expect(wrapper.find('[data-testid="manual-invoice-add-charge"]').text()).toContain('Add row') expect(wrapper.find('[data-testid="manual-invoice-add-charge"]').text()).toContain('Add row')
expect(wrapper.find('[data-testid="manual-invoice-submit"]').text()).toContain('Confirm and generate PDF') expect(wrapper.find('[data-testid="manual-invoice-submit"]').text()).toContain('Confirm and generate PDF')
expect(wrapper.text()).toContain('Row 1')
expect(wrapper.text()).not.toContain('第 1 行')
await wrapper.find('[data-testid="manual-invoice-add-charge"]').trigger('click') await wrapper.find('[data-testid="manual-invoice-add-charge"]').trigger('click')
expect(wrapper.findAll('.remove-button')[1]?.text()).toContain('Remove') expect(wrapper.findAll('.remove-button')[1]?.text()).toContain('Remove')
expect(wrapper.findAll('.remove-button')[1]?.attributes('aria-label')).toBe('Remove row 2') expect(wrapper.findAll('.remove-button')[1]?.attributes('aria-label')).toBe('Remove row 2')
expect(wrapper.text()).toContain('Row 2')
expect(wrapper.text()).not.toContain('新增一行') expect(wrapper.text()).not.toContain('新增一行')
expect(wrapper.text()).not.toContain('确认并生成 PDF') expect(wrapper.text()).not.toContain('确认并生成 PDF')
expect(wrapper.text()).not.toContain('例如 includingBF') expect(wrapper.text()).not.toContain('例如 includingBF')
expect(wrapper.text()).not.toContain('金额')
expect(wrapper.text()).not.toContain('说明')
})
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(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 () => { it('marks required fields and highlights empty inputs after submit', async () => {
@@ -326,16 +370,11 @@ describe('ReservationManualInvoiceView', () => {
) )
}) })
it('shows final-format date previews like the invoice prototype', async () => { it('does not render final-format date preview blocks', () => {
const wrapper = mountView() const wrapper = mountView()
await wrapper.find('[data-testid="booking-arrival-date"]').setValue('2026-08-02') expect(wrapper.find('.date-preview').exists()).toBe(false)
await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-08-05') expect(wrapper.text()).not.toContain('最终格式')
await wrapper.find('[data-testid="document-due-date"]').setValue('2026-07-22')
expect(wrapper.find('[data-testid="arrival-date-preview"]').text()).toContain('02/08/2026')
expect(wrapper.find('[data-testid="departure-date-preview"]').text()).toContain('05/08/2026')
expect(wrapper.find('[data-testid="due-date-preview"]').text()).toContain('22/07/2026')
}) })
it('keeps primary reservation controls bound to the first charge line', async () => { it('keeps primary reservation controls bound to the first charge line', async () => {

View File

@@ -201,23 +201,23 @@
>Booking Date</label> >Booking Date</label>
</div> </div>
<div class="date-control"> <div class="date-control">
<input <DatePicker
id="invoice-booking-date" v-model="documentBookingDateModel"
v-model="form.document.booking_date" input-id="invoice-booking-date"
data-testid="document-booking-date" class="localized-date-picker"
type="date" data-testid="document-booking-date-picker"
:class="validationClass('booking-date')" date-format="yy-mm-dd"
v-bind="validationAria('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"
required required
@input="markDocumentDatesEdited" @update:model-value="markDocumentDatesEdited"
> />
<output
class="date-preview"
for="invoice-booking-date"
>
<small>最终格式</small>
<strong>{{ formatDatePreview(form.document.booking_date) }}</strong>
</output>
</div> </div>
<p <p
v-if="fieldError('booking-date')" v-if="fieldError('booking-date')"
@@ -274,23 +274,22 @@
>Arrival Date</label> >Arrival Date</label>
</div> </div>
<div class="date-control"> <div class="date-control">
<input <DatePicker
id="invoice-arrival-date" v-model="bookingArrivalDateModel"
v-model="form.booking.arrival_date" input-id="invoice-arrival-date"
data-testid="booking-arrival-date" class="localized-date-picker"
type="date" data-testid="booking-arrival-date-picker"
:class="validationClass('arrival-date')" date-format="yy-mm-dd"
v-bind="validationAria('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"
required required
> />
<output
class="date-preview"
data-testid="arrival-date-preview"
for="invoice-arrival-date"
>
<small>最终格式</small>
<strong>{{ formatDatePreview(form.booking.arrival_date) }}</strong>
</output>
</div> </div>
<p <p
v-if="fieldError('arrival-date')" v-if="fieldError('arrival-date')"
@@ -310,23 +309,22 @@
>Departure Date</label> >Departure Date</label>
</div> </div>
<div class="date-control"> <div class="date-control">
<input <DatePicker
id="invoice-departure-date" v-model="bookingDepartureDateModel"
v-model="form.booking.departure_date" input-id="invoice-departure-date"
data-testid="booking-departure-date" class="localized-date-picker"
type="date" data-testid="booking-departure-date-picker"
:class="validationClass('departure-date')" date-format="yy-mm-dd"
v-bind="validationAria('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"
required required
> />
<output
class="date-preview"
data-testid="departure-date-preview"
for="invoice-departure-date"
>
<small>最终格式</small>
<strong>{{ formatDatePreview(form.booking.departure_date) }}</strong>
</output>
</div> </div>
<p <p
v-if="fieldError('departure-date')" v-if="fieldError('departure-date')"
@@ -346,24 +344,23 @@
>Due Date</label> >Due Date</label>
</div> </div>
<div class="date-control"> <div class="date-control">
<input <DatePicker
id="invoice-due-date" v-model="documentDueDateModel"
v-model="form.document.due_date" input-id="invoice-due-date"
data-testid="document-due-date" class="localized-date-picker"
type="date" data-testid="document-due-date-picker"
:class="validationClass('due-date')" date-format="yy-mm-dd"
v-bind="validationAria('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"
required required
@input="markDocumentDatesEdited" @update:model-value="markDocumentDatesEdited"
> />
<output
class="date-preview"
data-testid="due-date-preview"
for="invoice-due-date"
>
<small>最终格式</small>
<strong>{{ formatDatePreview(form.document.due_date) }}</strong>
</output>
</div> </div>
<p <p
v-if="fieldError('due-date')" v-if="fieldError('due-date')"
@@ -381,7 +378,6 @@
</div> </div>
<div class="room-rate-control"> <div class="room-rate-control">
<label> <label>
<span class="control-caption control-caption--required">金额</span>
<div class="number-control"> <div class="number-control">
<input <input
v-model="primaryCharge.rate" v-model="primaryCharge.rate"
@@ -408,7 +404,6 @@
</span> </span>
</label> </label>
<label> <label>
<span class="control-caption">说明</span>
<input <input
v-model.trim="form.booking.room_rate_note" v-model.trim="form.booking.room_rate_note"
data-testid="booking-room-rate-note" data-testid="booking-room-rate-note"
@@ -549,7 +544,7 @@
:data-testid="`charge-row-${index}`" :data-testid="`charge-row-${index}`"
> >
<header class="charge-line__header"> <header class="charge-line__header">
<strong> {{ index + 1 }} </strong> <strong>{{ t('manualInvoice.chargeLineLabel', { index: index + 1 }) }}</strong>
<button <button
type="button" type="button"
class="remove-button" class="remove-button"
@@ -817,6 +812,8 @@
</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'
@@ -836,6 +833,13 @@ 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
@@ -952,6 +956,7 @@ 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())
@@ -1000,6 +1005,121 @@ 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)
@@ -1019,6 +1139,14 @@ 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) => {
@@ -1338,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 { function chargeFieldKey(index: number, fieldName: ChargeFieldName): string {
return `charge-${index}-${fieldName}` return `charge-${index}-${fieldName}`
} }
@@ -1472,17 +1631,6 @@ function formatMoney(value: number, currency: string): string {
}).format(value) }).format(value)
} }
function formatDatePreview(value: string): string {
if (!value) {
return '—'
}
const [year, month, day] = value.split('-')
if (!year || !month || !day) {
return '—'
}
return `${day}/${month}/${year}`
}
function formatGenerationStatus(status: string): string { function formatGenerationStatus(status: string): string {
const key = `manualInvoice.statuses.${status}` const key = `manualInvoice.statuses.${status}`
return te(key) ? t(key) : status return te(key) ? t(key) : status
@@ -1540,6 +1688,40 @@ 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)
} }
@@ -1744,15 +1926,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
box-shadow: 0 0 0 3px rgb(225 29 72 / 12%), inset 0 0 0 1px rgb(225 29 72 / 8%); box-shadow: 0 0 0 3px rgb(225 29 72 / 12%), inset 0 0 0 1px rgb(225 29 72 / 8%);
} }
.date-control input.is-invalid + .date-preview,
.number-control input.is-invalid + .number-suffix { .number-control input.is-invalid + .number-suffix {
border-color: #e11d48; border-color: #e11d48;
background: #fff1f3; background: #fff1f3;
color: #be123c; color: #be123c;
} }
.field-label--required::after, .field-label--required::after {
.control-caption--required::after {
content: "*"; content: "*";
margin-left: 4px; margin-left: 4px;
color: #e11d48; color: #e11d48;
@@ -1778,19 +1958,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
gap: 10px; gap: 10px;
} }
.date-control, .date-control {
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;
min-width: 0; min-width: 0;
} }
.date-control input,
.number-control input { .number-control input {
border-radius: 6px 0 0 6px; border-radius: 6px 0 0 6px;
} }
.date-preview,
.number-suffix { .number-suffix {
display: grid; display: grid;
align-content: center; align-content: center;
@@ -1801,20 +1997,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
padding: 5px 10px; padding: 5px 10px;
} }
.date-preview {
min-width: 118px;
}
.date-preview small {
color: var(--th-color-slate-500);
font-size: 9px;
}
.date-preview strong {
color: var(--th-color-slate-600);
font-size: 11px;
}
.number-suffix { .number-suffix {
place-items: center; place-items: center;
min-width: 62px; min-width: 62px;
@@ -1836,12 +2018,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
min-width: 0; min-width: 0;
} }
.control-caption {
color: var(--th-color-slate-500);
font-size: 10px;
font-weight: 700;
}
.add-button, .add-button,
.remove-button, .remove-button,
.generate-button, .generate-button,
@@ -2204,17 +2380,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.date-control,
.number-control { .number-control {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.date-control input,
.number-control input { .number-control input {
border-radius: 6px 6px 0 0; border-radius: 6px 6px 0 0;
} }
.date-preview,
.number-suffix { .number-suffix {
min-height: 42px; min-height: 42px;
border-top: 0; border-top: 0;
@@ -2241,3 +2414,11 @@ 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>