优化手工开票表单校验提示
This commit is contained in:
@@ -315,6 +315,13 @@ export default {
|
||||
noPdfUrl: 'The backend did not return a PDF URL.',
|
||||
backendTotals: 'Backend totals',
|
||||
validationTitle: 'Please fix the form',
|
||||
fieldErrors: {
|
||||
required: 'Required',
|
||||
invalidEmail: 'Invalid email',
|
||||
dateOrder: 'Invalid date order',
|
||||
positiveNumber: 'Must be greater than 0',
|
||||
nonNegativeNumber: 'Cannot be less than 0',
|
||||
},
|
||||
statuses: {
|
||||
SUCCEEDED: 'Generated',
|
||||
GENERATED: 'Generated',
|
||||
|
||||
@@ -315,6 +315,13 @@ export default {
|
||||
noPdfUrl: 'หลังบ้านไม่ได้ส่งลิงก์ PDF กลับมา',
|
||||
backendTotals: 'ยอดจากหลังบ้าน',
|
||||
validationTitle: 'กรุณาแก้ไขแบบฟอร์ม',
|
||||
fieldErrors: {
|
||||
required: 'จำเป็นต้องกรอก',
|
||||
invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง',
|
||||
dateOrder: 'ลำดับวันที่ไม่ถูกต้อง',
|
||||
positiveNumber: 'ต้องมากกว่า 0',
|
||||
nonNegativeNumber: 'ต้องไม่ต่ำกว่า 0',
|
||||
},
|
||||
statuses: {
|
||||
SUCCEEDED: 'สร้างแล้ว',
|
||||
GENERATED: 'สร้างแล้ว',
|
||||
|
||||
@@ -315,6 +315,13 @@ export default {
|
||||
noPdfUrl: '后端未返回 PDF 链接。',
|
||||
backendTotals: '后端返回金额',
|
||||
validationTitle: '请先修正表单',
|
||||
fieldErrors: {
|
||||
required: '必填',
|
||||
invalidEmail: 'Email 格式不正确',
|
||||
dateOrder: '日期顺序不正确',
|
||||
positiveNumber: '必须大于 0',
|
||||
nonNegativeNumber: '不能小于 0',
|
||||
},
|
||||
statuses: {
|
||||
SUCCEEDED: '已生成',
|
||||
GENERATED: '已生成',
|
||||
|
||||
@@ -270,6 +270,62 @@ describe('ReservationManualInvoiceView', () => {
|
||||
expect(wrapper.text()).not.toContain('例如 includingBF')
|
||||
})
|
||||
|
||||
it('marks required fields and highlights empty inputs after submit', async () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
expect(wrapper.find('label[for="invoice-company"].field-label--required').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="recipient-company-input"]').classes()).not.toContain('is-invalid')
|
||||
|
||||
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.generateManualReservationInvoice).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="recipient-company-input"]').classes()).toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="recipient-company-input"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-field-error-company"]').text()).toBe(
|
||||
zhCN.manualInvoice.fieldErrors.required,
|
||||
)
|
||||
expect(wrapper.find('[data-testid="booking-group-name"]').classes()).toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="primary-room-count"]').classes()).toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="charge-description-0"]').classes()).toContain('is-invalid')
|
||||
|
||||
await wrapper.find('[data-testid="recipient-company-input"]').setValue('Manual Company')
|
||||
|
||||
expect(wrapper.find('[data-testid="recipient-company-input"]').classes()).not.toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-field-error-company"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows field-level validation messages for invalid email, dates, and numbers', async () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD')
|
||||
await fillMinimumInvoiceForm(wrapper)
|
||||
await wrapper.find('[data-testid="recipient-email"]').setValue('bad-email')
|
||||
await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-07-19')
|
||||
await wrapper.find('[data-testid="booking-extra-bed-rate"]').setValue('-1')
|
||||
await wrapper.find('[data-testid="charge-rate-0"]').setValue('0')
|
||||
await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.generateManualReservationInvoice).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="recipient-email"]').classes()).toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-field-error-email"]').text()).toBe(
|
||||
zhCN.manualInvoice.fieldErrors.invalidEmail,
|
||||
)
|
||||
expect(wrapper.find('[data-testid="booking-departure-date"]').classes()).toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-field-error-departure-date"]').text()).toBe(
|
||||
zhCN.manualInvoice.fieldErrors.dateOrder,
|
||||
)
|
||||
expect(wrapper.find('[data-testid="booking-extra-bed-rate"]').classes()).toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-field-error-extra-bed-rate"]').text()).toBe(
|
||||
zhCN.manualInvoice.fieldErrors.nonNegativeNumber,
|
||||
)
|
||||
expect(wrapper.find('[data-testid="charge-rate-0"]').classes()).toContain('is-invalid')
|
||||
expect(wrapper.find('[data-testid="manual-invoice-field-error-charge-0-rate"]').text()).toBe(
|
||||
zhCN.manualInvoice.fieldErrors.positiveNumber,
|
||||
)
|
||||
})
|
||||
|
||||
it('shows final-format date previews like the invoice prototype', async () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-company">Company</label>
|
||||
<label
|
||||
for="invoice-company"
|
||||
class="field-label field-label--required"
|
||||
>Company</label>
|
||||
</div>
|
||||
<div class="directory-control directory-control--manual">
|
||||
<select
|
||||
@@ -67,14 +70,27 @@
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('manualInvoice.companyInputPlaceholder')"
|
||||
:class="validationClass('company')"
|
||||
v-bind="validationAria('company')"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('company')"
|
||||
:id="fieldErrorId('company')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('company')"
|
||||
>
|
||||
{{ fieldError('company') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-attention">Attention</label>
|
||||
<label
|
||||
for="invoice-attention"
|
||||
class="field-label field-label--required"
|
||||
>Attention</label>
|
||||
</div>
|
||||
<div class="directory-control directory-control--manual">
|
||||
<select
|
||||
@@ -99,47 +115,90 @@
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('manualInvoice.attentionInputPlaceholder')"
|
||||
:class="validationClass('attention')"
|
||||
v-bind="validationAria('attention')"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('attention')"
|
||||
:id="fieldErrorId('attention')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('attention')"
|
||||
>
|
||||
{{ fieldError('attention') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="field field--wide">
|
||||
<span class="field-label">Address</span>
|
||||
<span class="field-label field-label--required">Address</span>
|
||||
<textarea
|
||||
v-model.trim="form.recipient.address"
|
||||
data-testid="recipient-address"
|
||||
rows="3"
|
||||
:placeholder="t('manualInvoice.addressInputPlaceholder')"
|
||||
:class="validationClass('address')"
|
||||
v-bind="validationAria('address')"
|
||||
required
|
||||
/>
|
||||
<span
|
||||
v-if="fieldError('address')"
|
||||
:id="fieldErrorId('address')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('address')"
|
||||
>
|
||||
{{ fieldError('address') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Tel</span>
|
||||
<span class="field-label field-label--required">Tel</span>
|
||||
<input
|
||||
v-model.trim="form.recipient.telephone"
|
||||
data-testid="recipient-telephone"
|
||||
type="tel"
|
||||
autocomplete="off"
|
||||
:class="validationClass('telephone')"
|
||||
v-bind="validationAria('telephone')"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError('telephone')"
|
||||
:id="fieldErrorId('telephone')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('telephone')"
|
||||
>
|
||||
{{ fieldError('telephone') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Email</span>
|
||||
<span class="field-label field-label--required">Email</span>
|
||||
<input
|
||||
v-model.trim="form.recipient.email"
|
||||
data-testid="recipient-email"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
:class="validationClass('email')"
|
||||
v-bind="validationAria('email')"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError('email')"
|
||||
:id="fieldErrorId('email')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('email')"
|
||||
>
|
||||
{{ fieldError('email') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-booking-date">Booking Date</label>
|
||||
<label
|
||||
for="invoice-booking-date"
|
||||
class="field-label field-label--required"
|
||||
>Booking Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
@@ -147,6 +206,8 @@
|
||||
v-model="form.document.booking_date"
|
||||
data-testid="document-booking-date"
|
||||
type="date"
|
||||
:class="validationClass('booking-date')"
|
||||
v-bind="validationAria('booking-date')"
|
||||
required
|
||||
@input="markDocumentDatesEdited"
|
||||
>
|
||||
@@ -158,6 +219,14 @@
|
||||
<strong>{{ formatDatePreview(form.document.booking_date) }}</strong>
|
||||
</output>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('booking-date')"
|
||||
:id="fieldErrorId('booking-date')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('booking-date')"
|
||||
>
|
||||
{{ fieldError('booking-date') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -177,19 +246,32 @@
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field field--wide">
|
||||
<span class="field-label">Group Name</span>
|
||||
<span class="field-label field-label--required">Group Name</span>
|
||||
<input
|
||||
v-model.trim="form.booking.group_name"
|
||||
data-testid="booking-group-name"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:class="validationClass('group-name')"
|
||||
v-bind="validationAria('group-name')"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError('group-name')"
|
||||
:id="fieldErrorId('group-name')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('group-name')"
|
||||
>
|
||||
{{ fieldError('group-name') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-arrival-date">Arrival Date</label>
|
||||
<label
|
||||
for="invoice-arrival-date"
|
||||
class="field-label field-label--required"
|
||||
>Arrival Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
@@ -197,6 +279,8 @@
|
||||
v-model="form.booking.arrival_date"
|
||||
data-testid="booking-arrival-date"
|
||||
type="date"
|
||||
:class="validationClass('arrival-date')"
|
||||
v-bind="validationAria('arrival-date')"
|
||||
required
|
||||
>
|
||||
<output
|
||||
@@ -208,11 +292,22 @@
|
||||
<strong>{{ formatDatePreview(form.booking.arrival_date) }}</strong>
|
||||
</output>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('arrival-date')"
|
||||
:id="fieldErrorId('arrival-date')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('arrival-date')"
|
||||
>
|
||||
{{ fieldError('arrival-date') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-departure-date">Departure Date</label>
|
||||
<label
|
||||
for="invoice-departure-date"
|
||||
class="field-label field-label--required"
|
||||
>Departure Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
@@ -220,6 +315,8 @@
|
||||
v-model="form.booking.departure_date"
|
||||
data-testid="booking-departure-date"
|
||||
type="date"
|
||||
:class="validationClass('departure-date')"
|
||||
v-bind="validationAria('departure-date')"
|
||||
required
|
||||
>
|
||||
<output
|
||||
@@ -231,11 +328,22 @@
|
||||
<strong>{{ formatDatePreview(form.booking.departure_date) }}</strong>
|
||||
</output>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('departure-date')"
|
||||
:id="fieldErrorId('departure-date')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('departure-date')"
|
||||
>
|
||||
{{ fieldError('departure-date') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-due-date">Due Date</label>
|
||||
<label
|
||||
for="invoice-due-date"
|
||||
class="field-label field-label--required"
|
||||
>Due Date</label>
|
||||
</div>
|
||||
<div class="date-control">
|
||||
<input
|
||||
@@ -243,6 +351,8 @@
|
||||
v-model="form.document.due_date"
|
||||
data-testid="document-due-date"
|
||||
type="date"
|
||||
:class="validationClass('due-date')"
|
||||
v-bind="validationAria('due-date')"
|
||||
required
|
||||
@input="markDocumentDatesEdited"
|
||||
>
|
||||
@@ -255,6 +365,14 @@
|
||||
<strong>{{ formatDatePreview(form.document.due_date) }}</strong>
|
||||
</output>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('due-date')"
|
||||
:id="fieldErrorId('due-date')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('due-date')"
|
||||
>
|
||||
{{ fieldError('due-date') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -263,7 +381,7 @@
|
||||
</div>
|
||||
<div class="room-rate-control">
|
||||
<label>
|
||||
<span class="control-caption">金额</span>
|
||||
<span class="control-caption control-caption--required">金额</span>
|
||||
<div class="number-control">
|
||||
<input
|
||||
v-model="primaryCharge.rate"
|
||||
@@ -271,6 +389,8 @@
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
:class="validationClass('primary-rate')"
|
||||
v-bind="validationAria('primary-rate')"
|
||||
required
|
||||
>
|
||||
<span
|
||||
@@ -278,6 +398,14 @@
|
||||
aria-hidden="true"
|
||||
>rm/n</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="fieldError('primary-rate')"
|
||||
:id="fieldErrorId('primary-rate')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('primary-rate')"
|
||||
>
|
||||
{{ fieldError('primary-rate') }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span class="control-caption">说明</span>
|
||||
@@ -300,12 +428,25 @@
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
:class="validationClass('extra-bed-rate')"
|
||||
v-bind="validationAria('extra-bed-rate')"
|
||||
>
|
||||
<span
|
||||
v-if="fieldError('extra-bed-rate')"
|
||||
:id="fieldErrorId('extra-bed-rate')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('extra-bed-rate')"
|
||||
>
|
||||
{{ fieldError('extra-bed-rate') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-room-count">No.of Room (s)</label>
|
||||
<label
|
||||
for="invoice-room-count"
|
||||
class="field-label field-label--required"
|
||||
>No.of Room (s)</label>
|
||||
</div>
|
||||
<div class="number-control">
|
||||
<input
|
||||
@@ -315,6 +456,8 @@
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:class="validationClass('primary-quantity')"
|
||||
v-bind="validationAria('primary-quantity')"
|
||||
required
|
||||
>
|
||||
<span
|
||||
@@ -322,11 +465,22 @@
|
||||
aria-hidden="true"
|
||||
>Rooms</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('primary-quantity')"
|
||||
:id="fieldErrorId('primary-quantity')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('primary-quantity')"
|
||||
>
|
||||
{{ fieldError('primary-quantity') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__heading">
|
||||
<label for="invoice-night-count">No.of Night (s)</label>
|
||||
<label
|
||||
for="invoice-night-count"
|
||||
class="field-label field-label--required"
|
||||
>No.of Night (s)</label>
|
||||
</div>
|
||||
<div class="number-control">
|
||||
<input
|
||||
@@ -336,6 +490,8 @@
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:class="validationClass('primary-nights')"
|
||||
v-bind="validationAria('primary-nights')"
|
||||
required
|
||||
>
|
||||
<span
|
||||
@@ -343,6 +499,14 @@
|
||||
aria-hidden="true"
|
||||
>Nights</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="fieldError('primary-nights')"
|
||||
:id="fieldErrorId('primary-nights')"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId('primary-nights')"
|
||||
>
|
||||
{{ fieldError('primary-nights') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -403,57 +567,107 @@
|
||||
|
||||
<div class="charge-grid">
|
||||
<label class="field charge-field--description">
|
||||
<span class="field-label">Description</span>
|
||||
<span class="field-label field-label--required">Description</span>
|
||||
<input
|
||||
v-model.trim="charge.description"
|
||||
:data-testid="`charge-description-${index}`"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:class="validationClass(chargeFieldKey(index, 'description'))"
|
||||
v-bind="validationAria(chargeFieldKey(index, 'description'))"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError(chargeFieldKey(index, 'description'))"
|
||||
:id="fieldErrorId(chargeFieldKey(index, 'description'))"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId(chargeFieldKey(index, 'description'))"
|
||||
>
|
||||
{{ fieldError(chargeFieldKey(index, 'description')) }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field charge-field--room-type">
|
||||
<span class="field-label">Room Types</span>
|
||||
<span class="field-label field-label--required">Room Types</span>
|
||||
<input
|
||||
v-model.trim="charge.room_type"
|
||||
:data-testid="`charge-room-type-${index}`"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:class="validationClass(chargeFieldKey(index, 'room-type'))"
|
||||
v-bind="validationAria(chargeFieldKey(index, 'room-type'))"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError(chargeFieldKey(index, 'room-type'))"
|
||||
:id="fieldErrorId(chargeFieldKey(index, 'room-type'))"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId(chargeFieldKey(index, 'room-type'))"
|
||||
>
|
||||
{{ fieldError(chargeFieldKey(index, 'room-type')) }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Quantity (s)</span>
|
||||
<span class="field-label field-label--required">Quantity (s)</span>
|
||||
<input
|
||||
v-model="charge.quantity"
|
||||
:data-testid="`charge-quantity-${index}`"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:class="validationClass(chargeFieldKey(index, 'quantity'))"
|
||||
v-bind="validationAria(chargeFieldKey(index, 'quantity'))"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError(chargeFieldKey(index, 'quantity'))"
|
||||
:id="fieldErrorId(chargeFieldKey(index, 'quantity'))"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId(chargeFieldKey(index, 'quantity'))"
|
||||
>
|
||||
{{ fieldError(chargeFieldKey(index, 'quantity')) }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Rate</span>
|
||||
<span class="field-label field-label--required">Rate</span>
|
||||
<input
|
||||
v-model="charge.rate"
|
||||
:data-testid="`charge-rate-${index}`"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
:class="validationClass(chargeFieldKey(index, 'rate'))"
|
||||
v-bind="validationAria(chargeFieldKey(index, 'rate'))"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError(chargeFieldKey(index, 'rate'))"
|
||||
:id="fieldErrorId(chargeFieldKey(index, 'rate'))"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId(chargeFieldKey(index, 'rate'))"
|
||||
>
|
||||
{{ fieldError(chargeFieldKey(index, 'rate')) }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Night (s)</span>
|
||||
<span class="field-label field-label--required">Night (s)</span>
|
||||
<input
|
||||
v-model="charge.nights"
|
||||
:data-testid="`charge-nights-${index}`"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
:class="validationClass(chargeFieldKey(index, 'nights'))"
|
||||
v-bind="validationAria(chargeFieldKey(index, 'nights'))"
|
||||
required
|
||||
>
|
||||
<span
|
||||
v-if="fieldError(chargeFieldKey(index, 'nights'))"
|
||||
:id="fieldErrorId(chargeFieldKey(index, 'nights'))"
|
||||
class="field-error"
|
||||
:data-testid="fieldErrorTestId(chargeFieldKey(index, 'nights'))"
|
||||
>
|
||||
{{ fieldError(chargeFieldKey(index, 'nights')) }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="charge-amount">
|
||||
<span>Amount (Bht)</span>
|
||||
@@ -650,6 +864,9 @@ interface GenerationErrorDescription {
|
||||
technicalDetails: string[]
|
||||
}
|
||||
|
||||
type FieldErrorMap = Record<string, string>
|
||||
type ChargeFieldName = 'description' | 'room-type' | 'quantity' | 'rate' | 'nights'
|
||||
|
||||
const recipientCompanies: RecipientCompanySeed[] = [
|
||||
{
|
||||
company_code: 'LIAN_TAI',
|
||||
@@ -772,6 +989,7 @@ const downloadMessageKey = ref('')
|
||||
const alertTone = ref<'error' | 'success'>('error')
|
||||
const generationResult = ref<ManualInvoiceGenerationResult | null>(null)
|
||||
const documentDatesEdited = ref(false)
|
||||
const validationAttempted = ref(false)
|
||||
|
||||
const recipientContacts = computed(() => {
|
||||
return recipientCompanies.find((company) => company.company_code === form.recipient.company_code)?.contacts ?? []
|
||||
@@ -781,6 +999,7 @@ 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 previewTotals = computed(() => {
|
||||
const total = form.charges.reduce((sum, charge) => sum + lineAmount(charge), 0)
|
||||
@@ -881,6 +1100,7 @@ function lineAmount(charge: ChargeForm): number {
|
||||
async function submitInvoice(): Promise<void> {
|
||||
technicalDetails.value = []
|
||||
downloadMessageKey.value = ''
|
||||
validationAttempted.value = true
|
||||
messages.value = validateForm()
|
||||
alertTone.value = 'error'
|
||||
if (messages.value.length > 0) {
|
||||
@@ -1007,7 +1227,7 @@ function validateForm(): string[] {
|
||||
) {
|
||||
errors.push(t('manualInvoice.errors.departureBeforeArrival'))
|
||||
}
|
||||
if (extraBedRate !== null && extraBedRate < 0) {
|
||||
if (form.booking.extra_bed_rate.trim() && (extraBedRate === null || extraBedRate < 0)) {
|
||||
errors.push(t('manualInvoice.errors.extraBedNegative'))
|
||||
}
|
||||
if (charges.some((charge) => charge.quantity <= 0 || charge.rate <= 0 || charge.nights <= 0)) {
|
||||
@@ -1016,6 +1236,112 @@ function validateForm(): string[] {
|
||||
return [...new Set(errors)]
|
||||
}
|
||||
|
||||
function collectFieldErrors(): FieldErrorMap {
|
||||
const errors: FieldErrorMap = {}
|
||||
const positiveNumber = t('manualInvoice.fieldErrors.positiveNumber')
|
||||
|
||||
setRequiredError(errors, 'company', form.recipient.company)
|
||||
setRequiredError(errors, 'attention', form.recipient.attention)
|
||||
setRequiredError(errors, 'address', form.recipient.address)
|
||||
setRequiredError(errors, 'telephone', form.recipient.telephone)
|
||||
setRequiredError(errors, 'email', form.recipient.email)
|
||||
if (form.recipient.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.recipient.email)) {
|
||||
errors.email = t('manualInvoice.fieldErrors.invalidEmail')
|
||||
}
|
||||
|
||||
setRequiredError(errors, 'booking-date', form.document.booking_date)
|
||||
setRequiredError(errors, 'due-date', form.document.due_date)
|
||||
if (form.document.booking_date && form.document.due_date && form.document.due_date < form.document.booking_date) {
|
||||
errors['due-date'] = t('manualInvoice.fieldErrors.dateOrder')
|
||||
}
|
||||
|
||||
setRequiredError(errors, 'group-name', form.booking.group_name)
|
||||
setRequiredError(errors, 'arrival-date', form.booking.arrival_date)
|
||||
setRequiredError(errors, 'departure-date', form.booking.departure_date)
|
||||
if (
|
||||
form.booking.arrival_date &&
|
||||
form.booking.departure_date &&
|
||||
form.booking.departure_date <= form.booking.arrival_date
|
||||
) {
|
||||
errors['departure-date'] = t('manualInvoice.fieldErrors.dateOrder')
|
||||
}
|
||||
|
||||
const extraBedRate = optionalNumberValue(form.booking.extra_bed_rate)
|
||||
if (form.booking.extra_bed_rate.trim() && (extraBedRate === null || extraBedRate < 0)) {
|
||||
errors['extra-bed-rate'] = t('manualInvoice.fieldErrors.nonNegativeNumber')
|
||||
}
|
||||
|
||||
form.charges.forEach((charge, index) => {
|
||||
setRequiredError(errors, chargeFieldKey(index, 'description'), charge.description)
|
||||
setRequiredError(errors, chargeFieldKey(index, 'room-type'), charge.room_type)
|
||||
if (numberValue(charge.quantity) <= 0) {
|
||||
errors[chargeFieldKey(index, 'quantity')] = positiveNumber
|
||||
}
|
||||
if (numberValue(charge.rate) <= 0) {
|
||||
errors[chargeFieldKey(index, 'rate')] = positiveNumber
|
||||
}
|
||||
if (numberValue(charge.nights) <= 0) {
|
||||
errors[chargeFieldKey(index, 'nights')] = positiveNumber
|
||||
}
|
||||
})
|
||||
|
||||
const firstQuantityError = errors[chargeFieldKey(0, 'quantity')]
|
||||
if (firstQuantityError) {
|
||||
errors['primary-quantity'] = firstQuantityError
|
||||
}
|
||||
const firstRateError = errors[chargeFieldKey(0, 'rate')]
|
||||
if (firstRateError) {
|
||||
errors['primary-rate'] = firstRateError
|
||||
}
|
||||
const firstNightsError = errors[chargeFieldKey(0, 'nights')]
|
||||
if (firstNightsError) {
|
||||
errors['primary-nights'] = firstNightsError
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
function setRequiredError(errors: FieldErrorMap, fieldKey: string, value: string): void {
|
||||
if (!value.trim()) {
|
||||
errors[fieldKey] = t('manualInvoice.fieldErrors.required')
|
||||
}
|
||||
}
|
||||
|
||||
function fieldError(fieldKey: string): string {
|
||||
return fieldErrors.value[fieldKey] ?? ''
|
||||
}
|
||||
|
||||
function fieldErrorId(fieldKey: string): string {
|
||||
return `manual-invoice-error-${fieldKey}`
|
||||
}
|
||||
|
||||
function fieldErrorTestId(fieldKey: string): string {
|
||||
return `manual-invoice-field-error-${fieldKey}`
|
||||
}
|
||||
|
||||
function validationClass(fieldKey: string): Record<string, boolean> {
|
||||
return {
|
||||
'is-invalid': Boolean(fieldError(fieldKey)),
|
||||
}
|
||||
}
|
||||
|
||||
function validationAria(fieldKey: string): Record<string, string | undefined> {
|
||||
if (!fieldError(fieldKey)) {
|
||||
return {
|
||||
'aria-invalid': undefined,
|
||||
'aria-describedby': undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
'aria-invalid': 'true',
|
||||
'aria-describedby': fieldErrorId(fieldKey),
|
||||
}
|
||||
}
|
||||
|
||||
function chargeFieldKey(index: number, fieldName: ChargeFieldName): string {
|
||||
return `charge-${index}-${fieldName}`
|
||||
}
|
||||
|
||||
function describeGenerationError(error: unknown): GenerationErrorDescription {
|
||||
if (error instanceof ApiError) {
|
||||
const authKey = error.status === 401 ? 'AUTH_401' : error.status === 403 ? 'AUTH_403' : null
|
||||
@@ -1371,14 +1697,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--th-color-border);
|
||||
border: 1px solid #b9c7dc;
|
||||
border-radius: 6px;
|
||||
background: var(--th-color-white);
|
||||
background: #fbfdff;
|
||||
color: var(--th-color-navy-950);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 10px 11px;
|
||||
outline: none;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 5%), inset 0 0 0 1px rgb(15 23 42 / 3%);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
@@ -1392,7 +1719,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
.field select:hover,
|
||||
.field textarea:hover,
|
||||
.charge-grid input:hover {
|
||||
border-color: #b9c8dc;
|
||||
border-color: #8fa4c2;
|
||||
}
|
||||
|
||||
.field input:focus-visible,
|
||||
@@ -1408,6 +1735,38 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.field input.is-invalid,
|
||||
.field select.is-invalid,
|
||||
.field textarea.is-invalid,
|
||||
.charge-grid input.is-invalid {
|
||||
border-color: #e11d48;
|
||||
background: #fff7f9;
|
||||
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 {
|
||||
border-color: #e11d48;
|
||||
background: #fff1f3;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.field-label--required::after,
|
||||
.control-caption--required::after {
|
||||
content: "*";
|
||||
margin-left: 4px;
|
||||
color: #e11d48;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin: -2px 0 0;
|
||||
color: #be123c;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.directory-control {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user