diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 4ef4775..4bff61b 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -253,6 +253,7 @@ export default { }, manualInvoice: { title: 'Manual invoice', + createTitle: 'Create Invoice', subtitle: 'Generate a Proforma Invoice PDF by entering fields manually without an order or task.', sourceBadge: 'Manual generation', independentHint: 'This V1 page is not linked to an order or task. The backend records generation and audit data.', @@ -272,12 +273,16 @@ export default { company: 'Company', attention: 'Attention', address: 'Address', + companyInputPlaceholder: 'Enter company', + attentionInputPlaceholder: 'Enter attention', + addressInputPlaceholder: 'Enter full billing address', telephone: 'Tel', email: 'Email', groupName: 'Group name', arrivalDate: 'Arrival date', departureDate: 'Departure date', roomRateNote: 'Room rate note', + roomRateNotePlaceholder: 'e.g. including BF', extraBedRate: 'Extra bed rate', description: 'Description', roomType: 'Room type', @@ -288,12 +293,13 @@ export default { subtotal: 'Sub-total', vat: 'VAT 7%', total: 'Total', - addCharge: 'Add charge', + addCharge: 'Add row', removeCharge: 'Remove', + removeChargeLineAria: 'Remove row {index}', maxChargesReached: 'V1 supports up to 10 charge lines.', previewDisclaimer: 'The frontend only previews amounts. Backend totals are authoritative.', manualOverrideHint: 'Selecting company and attention fills contact details; every text field remains editable.', - generate: 'Generate PDF', + generate: 'Confirm and generate PDF', generating: 'Generating', openPdf: 'Open PDF', downloadPdf: 'Download PDF', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 67f0e87..74d2a4f 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -253,6 +253,7 @@ export default { }, manualInvoice: { title: 'ออกใบแจ้งหนี้เอง', + createTitle: 'สร้าง Invoice', subtitle: 'กรอกข้อมูลเองเพื่อสร้าง Proforma Invoice PDF โดยไม่ต้องอ้างอิงออเดอร์หรืองาน', sourceBadge: 'สร้างด้วยตนเอง', independentHint: 'หน้า V1 นี้ไม่ผูกกับออเดอร์หรืองาน หลังบ้านจะบันทึกประวัติการสร้างและ audit', @@ -272,12 +273,16 @@ export default { company: 'Company', attention: 'Attention', address: 'Address', + companyInputPlaceholder: 'กรอกชื่อบริษัท', + attentionInputPlaceholder: 'กรอกชื่อผู้รับ', + addressInputPlaceholder: 'กรอกที่อยู่เรียกเก็บเงินแบบเต็ม', telephone: 'Tel', email: 'Email', groupName: 'Group Name', arrivalDate: 'Arrival Date', departureDate: 'Departure Date', roomRateNote: 'Room Rate Note', + roomRateNotePlaceholder: 'เช่น including BF', extraBedRate: 'Extra Bed Rate', description: 'Description', roomType: 'Room Type', @@ -288,12 +293,13 @@ export default { subtotal: 'Sub-Total', vat: 'VAT 7%', total: 'Total', - addCharge: 'เพิ่มรายการ', + addCharge: 'เพิ่มแถว', removeCharge: 'ลบ', + removeChargeLineAria: 'ลบแถวที่ {index}', maxChargesReached: 'V1 รองรับได้สูงสุด 10 รายการค่าใช้จ่าย', previewDisclaimer: 'ยอดนี้เป็นเพียงตัวอย่าง ยอดสุดท้ายให้ยึด totals จากหลังบ้าน', manualOverrideHint: 'เลือก Company / Attention แล้วระบบจะกรอกข้อมูลให้ แต่ยังแก้ไขข้อความได้ทั้งหมด', - generate: 'สร้าง PDF', + generate: 'ยืนยันและสร้าง PDF', generating: 'กำลังสร้าง', openPdf: 'เปิด PDF', downloadPdf: 'ดาวน์โหลด PDF', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index d672759..bac296f 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -253,6 +253,7 @@ export default { }, manualInvoice: { title: '手工开票', + createTitle: '创建 Invoice', subtitle: '无订单、无任务时,手工填写 Proforma Invoice 字段并生成 PDF。', sourceBadge: '手工生成', independentHint: '本页第一版不关联订单或任务,生成记录和审计由后端统一落库。', @@ -272,12 +273,16 @@ export default { company: '公司(Company)', attention: '收件人(Attention)', address: '地址(Address)', + companyInputPlaceholder: '输入公司名称', + attentionInputPlaceholder: '输入收件人', + addressInputPlaceholder: '输入客户完整账单地址', telephone: 'Tel', email: 'Email', groupName: '团队名称(Group Name)', arrivalDate: '到店日期(Arrival Date)', departureDate: '离店日期(Departure Date)', roomRateNote: '房价备注(Room Rate Note)', + roomRateNotePlaceholder: '例如 including BF', extraBedRate: '加床价格(Extra Bed Rate)', description: '费用描述(Description)', roomType: '房型(Room Type)', @@ -288,12 +293,13 @@ export default { subtotal: 'Sub-Total', vat: 'VAT 7%', total: 'Total', - addCharge: '新增费用', + addCharge: '新增一行', removeCharge: '删除', + removeChargeLineAria: '删除第 {index} 行', maxChargesReached: '第一版最多支持 10 条费用明细。', previewDisclaimer: '前端仅做金额预览,最终金额以后端返回 totals 为准。', manualOverrideHint: '选择 Company / Attention 后会自动带出资料,带出的文本仍可手工覆盖。', - generate: '生成 PDF', + generate: '确认并生成 PDF', generating: '生成中', openPdf: '打开 PDF', downloadPdf: '下载 PDF', diff --git a/client/src/tests/manualInvoiceView.spec.ts b/client/src/tests/manualInvoiceView.spec.ts index e39521b..4808d78 100644 --- a/client/src/tests/manualInvoiceView.spec.ts +++ b/client/src/tests/manualInvoiceView.spec.ts @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' +import enUS from '@/i18n/locales/en-US' +import thTH from '@/i18n/locales/th-TH' import zhCN from '@/i18n/locales/zh-CN' import { ApiError } from '@/services/httpClient' import { useAuthStore } from '@/stores/authStore' @@ -46,12 +48,17 @@ function createResult(): ManualInvoiceGenerationResult { const nativeCreateObjectURL = URL.createObjectURL const nativeRevokeObjectURL = URL.revokeObjectURL -function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[] } = {}) { +type ManualInvoiceTestLocale = 'zh-CN' | 'en-US' | 'th-TH' + +function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[]; locale?: ManualInvoiceTestLocale } = {}) { const i18n = createI18n({ legacy: false, - locale: 'zh-CN', + locale: options.locale ?? 'zh-CN', + fallbackLocale: 'zh-CN', messages: { 'zh-CN': zhCN, + 'en-US': enUS, + 'th-TH': thTH, }, }) const pinia = createPinia() @@ -80,11 +87,17 @@ function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[] menus: [], }) - return mount(ReservationManualInvoiceView, { + const wrapper = mount(ReservationManualInvoiceView, { global: { plugins: [i18n, pinia], }, }) + return Object.assign(wrapper, { + async setLocale(locale: ManualInvoiceTestLocale) { + i18n.global.locale.value = locale + await nextTick() + }, + }) } async function fillMinimumInvoiceForm(wrapper: ReturnType) { @@ -214,15 +227,49 @@ describe('ReservationManualInvoiceView', () => { expect(wrapper.find('[data-testid="manual-invoice-prototype-page"]').exists()).toBe(true) expect(wrapper.text()).toContain('创建 Invoice') + expect(wrapper.text()).not.toContain('草稿') expect(wrapper.text()).toContain('01') expect(wrapper.text()).toContain('Basic Information') expect(wrapper.text()).toContain('02') expect(wrapper.text()).toContain('Please refer to the following reservation') + expect(wrapper.text()).not.toContain('Group Name 与 Group Code 在本流程中为同一字段。') expect(wrapper.text()).toContain('03') expect(wrapper.text()).toContain('Description&Amount') expect(wrapper.find('[data-testid="manual-invoice-action-bar"]').exists()).toBe(true) }) + it('localizes the highlighted invoice title and recipient placeholders', () => { + const wrapper = mountView({ locale: 'en-US' }) + const placeholders = wrapper.findAll('input, textarea').map((field) => field.attributes('placeholder')) + + expect(wrapper.text()).toContain('Create Invoice') + expect(wrapper.text()).not.toContain('创建 Invoice') + expect(placeholders).toContain('Enter company') + expect(placeholders).toContain('Enter attention') + expect(placeholders).toContain('Enter full billing address') + expect(placeholders).not.toContain('输入 Company') + expect(placeholders).not.toContain('输入 Attention') + expect(placeholders).not.toContain('输入客户完整账单地址') + }) + + it('localizes the invoice action controls and room rate note placeholder', async () => { + const wrapper = mountView({ locale: 'en-US' }) + + expect((wrapper.find('[data-testid="booking-room-rate-note"]').element as HTMLInputElement).placeholder).toBe( + 'e.g. including BF', + ) + 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') + + 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]?.attributes('aria-label')).toBe('Remove row 2') + expect(wrapper.text()).not.toContain('新增一行') + expect(wrapper.text()).not.toContain('确认并生成 PDF') + expect(wrapper.text()).not.toContain('例如 includingBF') + }) + it('shows final-format date previews like the invoice prototype', async () => { const wrapper = mountView() @@ -287,8 +334,8 @@ describe('ReservationManualInvoiceView', () => { await wrapper.find('[data-testid="recipient-company-code"]').setValue('MANUAL') expect((wrapper.find('[data-testid="recipient-contact-id"]').element as HTMLSelectElement).value).toBe('MANUAL') - expect((wrapper.find('input[placeholder="输入 Company"]').element as HTMLInputElement).value).toBe('') - expect((wrapper.find('input[placeholder="输入 Attention"]').element as HTMLInputElement).value).toBe('') + expect((wrapper.find('[data-testid="recipient-company-input"]').element as HTMLInputElement).value).toBe('') + expect((wrapper.find('[data-testid="recipient-attention-input"]').element as HTMLInputElement).value).toBe('') expect((wrapper.find('[data-testid="recipient-email"]').element as HTMLInputElement).value).toBe('') }) @@ -370,6 +417,32 @@ describe('ReservationManualInvoiceView', () => { expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:manual-invoice-pdf') }) + it('localizes the PDF download fallback message after a locale switch', async () => { + vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) + const fetchMock = vi.fn().mockRejectedValue(new Error('CORS blocked')) + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + vi.stubGlobal('fetch', fetchMock) + const wrapper = mountView() + + await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD') + await fillMinimumInvoiceForm(wrapper) + await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click') + await flushPromises() + await wrapper.find('[data-testid="manual-invoice-download-pdf"]').trigger('click') + await flushPromises() + + expect(openSpy).toHaveBeenCalledWith('https://oss.example/invoices/manual-91001.pdf', '_blank', 'noopener,noreferrer') + expect(wrapper.find('[data-testid="manual-invoice-download-message"]').text()).toBe( + zhCN.manualInvoice.downloadFallback, + ) + + await wrapper.setLocale('en-US') + + expect(wrapper.find('[data-testid="manual-invoice-download-message"]').text()).toBe( + enUS.manualInvoice.downloadFallback, + ) + }) + it('submits complete numeric input values instead of parseFloat prefixes', async () => { vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) const wrapper = mountView() diff --git a/client/src/views/reservation/ReservationManualInvoiceView.vue b/client/src/views/reservation/ReservationManualInvoiceView.vue index 2565d9d..9ea1e16 100644 --- a/client/src/views/reservation/ReservationManualInvoiceView.vue +++ b/client/src/views/reservation/ReservationManualInvoiceView.vue @@ -5,14 +5,7 @@ >
-

创建 Invoice

- -