2294 lines
66 KiB
Vue
2294 lines
66 KiB
Vue
<template>
|
|
<main
|
|
class="invoice-page"
|
|
data-testid="manual-invoice-prototype-page"
|
|
>
|
|
<header class="invoice-page__header">
|
|
<div class="invoice-page__title">
|
|
<h1>{{ t('manualInvoice.createTitle') }}</h1>
|
|
</div>
|
|
<div class="source-summary">
|
|
<span>当前来源</span>
|
|
<strong>{{ sourceGroupName }}</strong>
|
|
<small>Manual Invoice Mode</small>
|
|
</div>
|
|
</header>
|
|
|
|
<form
|
|
class="invoice-form"
|
|
@submit.prevent="submitInvoice"
|
|
>
|
|
<input
|
|
v-model="form.document.invoice_date"
|
|
data-testid="document-invoice-date"
|
|
type="hidden"
|
|
@input="markDocumentDatesEdited"
|
|
>
|
|
|
|
<div class="invoice-form-column">
|
|
<section
|
|
class="section-card"
|
|
aria-labelledby="basic-information-title"
|
|
>
|
|
<header class="section-header">
|
|
<div class="section-title">
|
|
<span class="section-index">01</span>
|
|
<h2 id="basic-information-title">
|
|
Basic Information
|
|
</h2>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<div class="field__heading">
|
|
<label
|
|
for="invoice-company"
|
|
class="field-label field-label--required"
|
|
>Company</label>
|
|
</div>
|
|
<div class="directory-control directory-control--manual">
|
|
<select
|
|
id="invoice-company"
|
|
v-model="form.recipient.company_code"
|
|
data-testid="recipient-company-code"
|
|
>
|
|
<option :value="MANUAL_SELECTION">
|
|
Manual
|
|
</option>
|
|
<option
|
|
v-for="company in recipientCompanies"
|
|
:key="company.company_code"
|
|
:value="company.company_code"
|
|
>
|
|
{{ company.company }}
|
|
</option>
|
|
</select>
|
|
<input
|
|
v-model.trim="form.recipient.company"
|
|
data-testid="recipient-company-input"
|
|
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"
|
|
class="field-label field-label--required"
|
|
>Attention</label>
|
|
</div>
|
|
<div class="directory-control directory-control--manual">
|
|
<select
|
|
id="invoice-attention"
|
|
v-model="form.recipient.contact_id"
|
|
data-testid="recipient-contact-id"
|
|
>
|
|
<option :value="MANUAL_SELECTION">
|
|
Manual
|
|
</option>
|
|
<option
|
|
v-for="contact in recipientContacts"
|
|
:key="contact.contact_id"
|
|
:value="contact.contact_id"
|
|
>
|
|
{{ contact.attention }}
|
|
</option>
|
|
</select>
|
|
<input
|
|
v-model.trim="form.recipient.attention"
|
|
data-testid="recipient-attention-input"
|
|
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 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 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 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"
|
|
class="field-label field-label--required"
|
|
>Booking Date</label>
|
|
</div>
|
|
<div class="date-control">
|
|
<LocalizedDatePicker
|
|
v-model="form.document.booking_date"
|
|
input-id="invoice-booking-date"
|
|
test-id="document-booking-date"
|
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
|
:input-class="validationClass('booking-date')"
|
|
:invalid="Boolean(fieldError('booking-date'))"
|
|
:described-by="fieldError('booking-date') ? fieldErrorId('booking-date') : undefined"
|
|
required
|
|
@update:model-value="markDocumentDatesEdited"
|
|
/>
|
|
</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>
|
|
|
|
<section
|
|
class="section-card"
|
|
aria-labelledby="reservation-title"
|
|
>
|
|
<header class="section-header">
|
|
<div class="section-title">
|
|
<span class="section-index">02</span>
|
|
<h2 id="reservation-title">
|
|
Please refer to the following reservation
|
|
</h2>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="form-grid">
|
|
<label class="field field--wide">
|
|
<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"
|
|
class="field-label field-label--required"
|
|
>Arrival Date</label>
|
|
</div>
|
|
<div class="date-control">
|
|
<LocalizedDatePicker
|
|
v-model="form.booking.arrival_date"
|
|
input-id="invoice-arrival-date"
|
|
test-id="booking-arrival-date"
|
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
|
:input-class="validationClass('arrival-date')"
|
|
:invalid="Boolean(fieldError('arrival-date'))"
|
|
:described-by="fieldError('arrival-date') ? fieldErrorId('arrival-date') : undefined"
|
|
required
|
|
@update:model-value="derivePrimaryNightsFromStayDates"
|
|
/>
|
|
</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"
|
|
class="field-label field-label--required"
|
|
>Departure Date</label>
|
|
</div>
|
|
<div class="date-control">
|
|
<LocalizedDatePicker
|
|
v-model="form.booking.departure_date"
|
|
input-id="invoice-departure-date"
|
|
test-id="booking-departure-date"
|
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
|
:input-class="validationClass('departure-date')"
|
|
:invalid="Boolean(fieldError('departure-date'))"
|
|
:described-by="fieldError('departure-date') ? fieldErrorId('departure-date') : undefined"
|
|
required
|
|
@update:model-value="derivePrimaryNightsFromStayDates"
|
|
/>
|
|
</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"
|
|
class="field-label field-label--required"
|
|
>Due Date</label>
|
|
</div>
|
|
<div class="date-control">
|
|
<LocalizedDatePicker
|
|
v-model="form.document.due_date"
|
|
input-id="invoice-due-date"
|
|
test-id="document-due-date"
|
|
:placeholder="t('manualInvoice.datePlaceholder')"
|
|
:input-class="validationClass('due-date')"
|
|
:invalid="Boolean(fieldError('due-date'))"
|
|
:described-by="fieldError('due-date') ? fieldErrorId('due-date') : undefined"
|
|
required
|
|
@update:model-value="markDocumentDatesEdited"
|
|
/>
|
|
</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">
|
|
<div class="field__heading">
|
|
<span class="field-label">Room Rate</span>
|
|
</div>
|
|
<div class="room-rate-control">
|
|
<label>
|
|
<div class="number-control">
|
|
<input
|
|
v-model="primaryCharge.rate"
|
|
data-testid="primary-room-rate"
|
|
type="text"
|
|
inputmode="decimal"
|
|
autocomplete="off"
|
|
:class="validationClass('primary-rate')"
|
|
v-bind="validationAria('primary-rate')"
|
|
required
|
|
>
|
|
<span
|
|
class="number-suffix"
|
|
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>
|
|
<input
|
|
v-model.trim="form.booking.room_rate_note"
|
|
data-testid="booking-room-rate-note"
|
|
type="text"
|
|
:placeholder="t('manualInvoice.roomRateNotePlaceholder')"
|
|
autocomplete="off"
|
|
>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<label class="field">
|
|
<span class="field-label">Extra bed</span>
|
|
<input
|
|
v-model="form.booking.extra_bed_rate"
|
|
data-testid="booking-extra-bed-rate"
|
|
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"
|
|
class="field-label field-label--required"
|
|
>No.of Room (s)</label>
|
|
</div>
|
|
<div class="number-control">
|
|
<input
|
|
id="invoice-room-count"
|
|
v-model="primaryCharge.quantity"
|
|
data-testid="primary-room-count"
|
|
type="text"
|
|
inputmode="numeric"
|
|
autocomplete="off"
|
|
:class="validationClass('primary-quantity')"
|
|
v-bind="validationAria('primary-quantity')"
|
|
required
|
|
>
|
|
<span
|
|
class="number-suffix"
|
|
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"
|
|
class="field-label field-label--required"
|
|
>No.of Night (s)</label>
|
|
</div>
|
|
<div class="number-control">
|
|
<input
|
|
id="invoice-night-count"
|
|
v-model="primaryCharge.nights"
|
|
data-testid="primary-night-count"
|
|
type="text"
|
|
inputmode="numeric"
|
|
autocomplete="off"
|
|
:class="validationClass('primary-nights')"
|
|
v-bind="validationAria('primary-nights')"
|
|
required
|
|
@input="markPrimaryNightsEdited"
|
|
>
|
|
<span
|
|
class="number-suffix"
|
|
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>
|
|
|
|
<section
|
|
class="section-card"
|
|
aria-labelledby="description-amount-title"
|
|
>
|
|
<header class="section-header">
|
|
<div class="section-title">
|
|
<span class="section-index">03</span>
|
|
<h2 id="description-amount-title">
|
|
Description&Amount
|
|
</h2>
|
|
</div>
|
|
<div class="section-actions">
|
|
<span class="currency-badge">BHT</span>
|
|
<button
|
|
id="add-charge"
|
|
type="button"
|
|
class="add-button"
|
|
data-testid="manual-invoice-add-charge"
|
|
:disabled="form.charges.length >= MAX_CHARGE_LINES"
|
|
@click="addCharge"
|
|
>
|
|
<i
|
|
class="pi pi-plus"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ t('manualInvoice.addCharge') }}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="charge-lines">
|
|
<article
|
|
v-for="(charge, index) in form.charges"
|
|
:key="charge.id"
|
|
class="charge-line"
|
|
:data-testid="`charge-row-${index}`"
|
|
>
|
|
<header class="charge-line__header">
|
|
<strong>{{ t('manualInvoice.chargeLineLabel', { index: index + 1 }) }}</strong>
|
|
<button
|
|
type="button"
|
|
class="remove-button"
|
|
:disabled="form.charges.length === 1"
|
|
:aria-label="t('manualInvoice.removeChargeLineAria', { index: index + 1 })"
|
|
@click="removeCharge(index)"
|
|
>
|
|
<i
|
|
class="pi pi-trash"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ t('manualInvoice.removeCharge') }}
|
|
</button>
|
|
</header>
|
|
|
|
<div class="charge-grid">
|
|
<label class="field charge-field--description">
|
|
<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 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 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 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 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
|
|
@input="markChargeNightsEdited(index)"
|
|
>
|
|
<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>
|
|
<output :data-testid="`charge-amount-${index}`">
|
|
{{ formatMoney(lineAmount(charge), previewCurrency) }}
|
|
</output>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
|
|
<footer class="results">
|
|
<dl>
|
|
<div>
|
|
<dt>Sub-Total</dt>
|
|
<dd>{{ formatMoney(previewTotals.subtotal, previewCurrency) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>VAT 7%</dt>
|
|
<dd>{{ formatMoney(previewTotals.vat, previewCurrency) }}</dd>
|
|
</div>
|
|
<div class="total-row">
|
|
<dt>Total amount</dt>
|
|
<dd>{{ formatMoney(previewTotals.total, previewCurrency) }}</dd>
|
|
</div>
|
|
</dl>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
|
|
<footer
|
|
class="action-bar"
|
|
data-testid="manual-invoice-action-bar"
|
|
>
|
|
<button
|
|
type="submit"
|
|
class="generate-button"
|
|
data-testid="manual-invoice-submit"
|
|
:disabled="submitting"
|
|
@click.prevent="submitInvoice"
|
|
>
|
|
<i
|
|
class="pi pi-file-pdf"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ submitting ? t('manualInvoice.generating') : t('manualInvoice.generate') }}
|
|
</button>
|
|
</footer>
|
|
|
|
<section
|
|
v-if="messages.length > 0"
|
|
class="section-card invoice-alert"
|
|
:class="`invoice-alert--${alertTone}`"
|
|
>
|
|
<strong>{{ alertTitle }}</strong>
|
|
<ul data-testid="manual-invoice-user-messages">
|
|
<li
|
|
v-for="message in messages"
|
|
:key="message"
|
|
>
|
|
{{ message }}
|
|
</li>
|
|
</ul>
|
|
<details
|
|
v-if="technicalDetails.length > 0"
|
|
class="technical-details"
|
|
data-testid="manual-invoice-technical-details"
|
|
>
|
|
<summary>{{ t('manualInvoice.technicalDetails') }}</summary>
|
|
<ul>
|
|
<li
|
|
v-for="detail in technicalDetails"
|
|
:key="detail"
|
|
>
|
|
{{ detail }}
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
</section>
|
|
|
|
<section
|
|
v-if="generationResult"
|
|
class="section-card result-card"
|
|
>
|
|
<header class="section-header">
|
|
<div class="section-title">
|
|
<span class="section-index">PDF</span>
|
|
<h2>{{ t('manualInvoice.resultSection') }}</h2>
|
|
</div>
|
|
</header>
|
|
<dl class="result-list">
|
|
<div>
|
|
<dt>{{ t('manualInvoice.generationId') }}</dt>
|
|
<dd>{{ generationResult.invoice_generation_id }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('manualInvoice.generationStatus') }}</dt>
|
|
<dd>{{ formatGenerationStatus(generationResult.generation_status) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('manualInvoice.createdAt') }}</dt>
|
|
<dd>{{ formatReservationDateTime(generationResult.created_at) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('manualInvoice.total') }}</dt>
|
|
<dd>{{ formatMoney(generationResult.totals.total, generationResult.totals.currency) }}</dd>
|
|
</div>
|
|
</dl>
|
|
<div
|
|
v-if="generationResult.pdf_url"
|
|
class="result-actions"
|
|
>
|
|
<a
|
|
class="primary-link"
|
|
:href="generationResult.pdf_url"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{{ t('manualInvoice.openPdf') }}
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="secondary-link"
|
|
data-testid="manual-invoice-download-pdf"
|
|
:disabled="downloadingPdf"
|
|
@click="downloadPdf"
|
|
>
|
|
{{ downloadingPdf ? t('manualInvoice.downloadingPdf') : t('manualInvoice.downloadPdf') }}
|
|
</button>
|
|
</div>
|
|
<p
|
|
v-if="downloadMessage"
|
|
class="muted-text"
|
|
data-testid="manual-invoice-download-message"
|
|
>
|
|
{{ downloadMessage }}
|
|
</p>
|
|
<p
|
|
v-if="!generationResult.pdf_url"
|
|
class="muted-text"
|
|
>
|
|
{{ t('manualInvoice.noPdfUrl') }}
|
|
</p>
|
|
</section>
|
|
</form>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, reactive, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import LocalizedDatePicker from '@/components/common/LocalizedDatePicker.vue'
|
|
import { reservationTimeZone } from '@/config/reservationConfig'
|
|
import { ApiError } from '@/services/httpClient'
|
|
import { generateManualReservationInvoice } from '@/services/reservationService'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import type {
|
|
ManualInvoiceChargePayload,
|
|
ManualInvoiceGenerationRequest,
|
|
ManualInvoiceGenerationResult,
|
|
} from '@/types/manualInvoice'
|
|
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
|
|
|
const MAX_CHARGE_LINES = 10
|
|
const MANUAL_SELECTION = 'MANUAL'
|
|
const VAT_RATE = 0.07
|
|
const previewCurrency = 'THB'
|
|
|
|
interface RecipientContactSeed {
|
|
contact_id: string
|
|
attention: string
|
|
address: string
|
|
telephone: string
|
|
email: string
|
|
}
|
|
|
|
interface RecipientCompanySeed {
|
|
company_code: string
|
|
company: string
|
|
contacts: RecipientContactSeed[]
|
|
}
|
|
|
|
interface ChargeForm {
|
|
id: number
|
|
description: string
|
|
room_type: string
|
|
quantity: string
|
|
rate: string
|
|
nights: string
|
|
}
|
|
|
|
interface GenerationErrorDescription {
|
|
message: string
|
|
technicalDetails: string[]
|
|
}
|
|
|
|
type FieldErrorMap = Record<string, string>
|
|
type ChargeFieldName = 'description' | 'room-type' | 'quantity' | 'rate' | 'nights'
|
|
|
|
const recipientCompanies: RecipientCompanySeed[] = [
|
|
{
|
|
company_code: 'LIAN_TAI',
|
|
company: 'LIAN TAI TRAVEL (THAILAND) CO., LTD.',
|
|
contacts: [
|
|
{
|
|
contact_id: 'LIAN_TAI_KHUN_ANN',
|
|
attention: 'Khun Ann',
|
|
address: '2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240',
|
|
telephone: '061-397-2675',
|
|
email: 'op.liantaitravel@gmail.com',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
company_code: 'QBD',
|
|
company: 'Q.B.D. TRAVEL GROUP CO., LTD',
|
|
contacts: [
|
|
{
|
|
contact_id: 'QBD_JITDANUN_PANAPHUCHONG',
|
|
attention: 'Jitdanun Panaphuchong',
|
|
address: '2/90 Rajpattana,Rajpattana,Sapansoong, Bangkok, TH, 10240',
|
|
telephone: '089-032 0176',
|
|
email: 'op.qbdtravel@gmail.com',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
company_code: 'HANATOUR',
|
|
company: 'HANATOUR TD CO., LTD.',
|
|
contacts: [
|
|
{
|
|
contact_id: 'HANATOUR_WICHIENPRAKARN',
|
|
attention: 'Wichienprakarn, Nuanphae, Khun.',
|
|
address: 'HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR',
|
|
telephone: '066 124 - 7297',
|
|
email: 'HI219@hanatour.com',
|
|
},
|
|
{
|
|
contact_id: 'HANATOUR_PHONGBUPPA',
|
|
attention: 'Phongbuppa, Buppachat',
|
|
address: 'HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR',
|
|
telephone: '096 051 3587',
|
|
email: 'HI223@hanatour.com',
|
|
},
|
|
{
|
|
contact_id: 'HANATOUR_KANG_SUNG_HWA',
|
|
attention: 'Kang,Sung Hwa',
|
|
address: 'HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR',
|
|
telephone: '82 051 804 0707',
|
|
email: 'k8040707@nave.com',
|
|
},
|
|
{
|
|
contact_id: 'HANATOUR_MINYOUNG_KIM',
|
|
attention: 'Minyoung Kim',
|
|
address: 'HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR',
|
|
telephone: '82 010 6638 3345',
|
|
email: 'mykim1220@hanayour.com',
|
|
},
|
|
{
|
|
contact_id: 'HANATOUR_BOLAM_JO',
|
|
attention: 'Bolam Jo',
|
|
address: 'HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR',
|
|
telephone: '82 010 4182 4615',
|
|
email: 'melissa0609@hanatour.com',
|
|
},
|
|
{
|
|
contact_id: 'HANATOUR_HWANG_SEONGSEOP',
|
|
attention: 'Hwang Seongseop',
|
|
address: 'HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR',
|
|
telephone: '82 010 3167 8648',
|
|
email: 'hwangpilot59@naver.com',
|
|
},
|
|
{
|
|
contact_id: 'HANATOUR_SONG_SAE_HWA',
|
|
attention: 'Song Sae Hwa',
|
|
address: 'HanaTour Bldg, 41,Insadong 5-gil, Jongno-gu, KR',
|
|
telephone: '82 010 2400 6801',
|
|
email: 'sh-tour2016@naver.com',
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
const { t, te, locale } = useI18n()
|
|
const authStore = useAuthStore()
|
|
let nextChargeId = 1
|
|
const defaultDocumentDates = createDefaultDocumentDates(resolveHotelTimeZone())
|
|
|
|
const form = reactive({
|
|
document: {
|
|
invoice_date: defaultDocumentDates.invoiceDate,
|
|
booking_date: defaultDocumentDates.bookingDate,
|
|
due_date: defaultDocumentDates.dueDate,
|
|
},
|
|
recipient: {
|
|
company_code: '',
|
|
contact_id: '',
|
|
company: '',
|
|
attention: '',
|
|
address: '',
|
|
telephone: '',
|
|
email: '',
|
|
},
|
|
booking: {
|
|
group_name: '',
|
|
arrival_date: '',
|
|
departure_date: '',
|
|
room_rate_note: '',
|
|
extra_bed_rate: '',
|
|
},
|
|
charges: [createCharge()],
|
|
})
|
|
|
|
const submitting = ref(false)
|
|
const downloadingPdf = ref(false)
|
|
const messages = ref<string[]>([])
|
|
const technicalDetails = ref<string[]>([])
|
|
const downloadMessageKey = ref('')
|
|
const alertTone = ref<'error' | 'success'>('error')
|
|
const generationResult = ref<ManualInvoiceGenerationResult | null>(null)
|
|
const documentDatesEdited = ref(false)
|
|
const primaryNightsEdited = ref(false)
|
|
const validationAttempted = ref(false)
|
|
|
|
const recipientContacts = computed(() => {
|
|
return recipientCompanies.find((company) => company.company_code === form.recipient.company_code)?.contacts ?? []
|
|
})
|
|
|
|
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)
|
|
const subtotal = total / (1 + VAT_RATE)
|
|
const vat = total - subtotal
|
|
return {
|
|
subtotal,
|
|
vat,
|
|
total,
|
|
}
|
|
})
|
|
|
|
const alertTitle = computed(() => {
|
|
if (alertTone.value === 'success') {
|
|
return t('manualInvoice.resultSection')
|
|
}
|
|
return t('manualInvoice.validationTitle')
|
|
})
|
|
|
|
watch(
|
|
() => form.recipient.company_code,
|
|
(companyCode) => {
|
|
if (companyCode === MANUAL_SELECTION) {
|
|
applyManualCompanySelection()
|
|
return
|
|
}
|
|
const company = recipientCompanies.find((item) => item.company_code === companyCode)
|
|
if (!company) {
|
|
form.recipient.contact_id = ''
|
|
return
|
|
}
|
|
form.recipient.company = company.company
|
|
if (!company.contacts.some((contact) => contact.contact_id === form.recipient.contact_id)) {
|
|
form.recipient.contact_id = company.contacts[0]?.contact_id ?? ''
|
|
}
|
|
applyContactSeed(form.recipient.contact_id)
|
|
},
|
|
)
|
|
|
|
watch(
|
|
() => form.recipient.contact_id,
|
|
(contactId) => {
|
|
if (contactId === MANUAL_SELECTION) {
|
|
clearRecipientContactProfile()
|
|
return
|
|
}
|
|
applyContactSeed(contactId)
|
|
},
|
|
)
|
|
|
|
watch(
|
|
() => authStore.selectedHotel?.time_zone,
|
|
(timeZone, previousTimeZone) => {
|
|
if (!timeZone || timeZone === previousTimeZone || documentDatesEdited.value) {
|
|
return
|
|
}
|
|
applyDefaultDocumentDates(timeZone)
|
|
},
|
|
)
|
|
|
|
function createCharge(): ChargeForm {
|
|
const id = nextChargeId
|
|
nextChargeId += 1
|
|
return {
|
|
id,
|
|
description: '',
|
|
room_type: '',
|
|
quantity: '',
|
|
rate: '',
|
|
nights: '',
|
|
}
|
|
}
|
|
|
|
function addCharge(): void {
|
|
if (form.charges.length >= MAX_CHARGE_LINES) {
|
|
return
|
|
}
|
|
form.charges.push(createCharge())
|
|
}
|
|
|
|
function removeCharge(index: number): void {
|
|
if (form.charges.length === 1) {
|
|
return
|
|
}
|
|
form.charges.splice(index, 1)
|
|
}
|
|
|
|
function lineAmount(charge: ChargeForm): number {
|
|
const quantity = numberValue(charge.quantity)
|
|
const rate = numberValue(charge.rate)
|
|
const nights = numberValue(charge.nights)
|
|
if (quantity <= 0 || rate <= 0 || nights <= 0) {
|
|
return 0
|
|
}
|
|
return quantity * rate * nights
|
|
}
|
|
|
|
async function submitInvoice(): Promise<void> {
|
|
technicalDetails.value = []
|
|
downloadMessageKey.value = ''
|
|
validationAttempted.value = true
|
|
messages.value = validateForm()
|
|
alertTone.value = 'error'
|
|
if (messages.value.length > 0) {
|
|
return
|
|
}
|
|
|
|
submitting.value = true
|
|
generationResult.value = null
|
|
try {
|
|
generationResult.value = await generateManualReservationInvoice(buildRequest())
|
|
messages.value = []
|
|
} catch (error) {
|
|
const errorDescription = describeGenerationError(error)
|
|
messages.value = [errorDescription.message]
|
|
technicalDetails.value = errorDescription.technicalDetails
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function downloadPdf(): Promise<void> {
|
|
const result = generationResult.value
|
|
const pdfUrl = result?.pdf_url
|
|
if (!pdfUrl || downloadingPdf.value) {
|
|
return
|
|
}
|
|
|
|
downloadingPdf.value = true
|
|
downloadMessageKey.value = ''
|
|
try {
|
|
const response = await fetch(pdfUrl, { credentials: 'omit' })
|
|
if (!response.ok) {
|
|
throw new Error(`PDF download failed with status ${response.status}`)
|
|
}
|
|
const blob = await response.blob()
|
|
const objectUrl = URL.createObjectURL(blob)
|
|
triggerBrowserDownload(objectUrl, createPdfFileName(result.invoice_generation_id))
|
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
|
|
} catch {
|
|
downloadMessageKey.value = 'manualInvoice.downloadFallback'
|
|
window.open(pdfUrl, '_blank', 'noopener,noreferrer')
|
|
} finally {
|
|
downloadingPdf.value = false
|
|
}
|
|
}
|
|
|
|
function buildRequest(): ManualInvoiceGenerationRequest {
|
|
return {
|
|
hotel_id: authStore.selectedHotelId ?? undefined,
|
|
source_type: 'MANUAL',
|
|
task_id: null,
|
|
order_id: null,
|
|
template_code: 'PROFORMA_INVOICE_V1',
|
|
invoice_payload: {
|
|
document: {
|
|
invoice_date: form.document.invoice_date,
|
|
booking_date: form.document.booking_date,
|
|
due_date: form.document.due_date,
|
|
},
|
|
recipient: {
|
|
company_code: directoryCodeOrNull(form.recipient.company_code),
|
|
contact_id: directoryCodeOrNull(form.recipient.contact_id),
|
|
company: form.recipient.company.trim(),
|
|
attention: form.recipient.attention.trim(),
|
|
address: form.recipient.address.trim(),
|
|
telephone: form.recipient.telephone.trim(),
|
|
email: form.recipient.email.trim(),
|
|
},
|
|
booking: {
|
|
group_name: form.booking.group_name.trim(),
|
|
arrival_date: form.booking.arrival_date,
|
|
departure_date: form.booking.departure_date,
|
|
room_rate_note: trimToNull(form.booking.room_rate_note),
|
|
extra_bed_rate: optionalNumberValue(form.booking.extra_bed_rate),
|
|
},
|
|
charges: buildCharges(),
|
|
},
|
|
}
|
|
}
|
|
|
|
function buildCharges(): ManualInvoiceChargePayload[] {
|
|
return form.charges.map((charge) => ({
|
|
description: charge.description.trim(),
|
|
room_type: charge.room_type.trim(),
|
|
quantity: numberValue(charge.quantity),
|
|
rate: numberValue(charge.rate),
|
|
nights: numberValue(charge.nights),
|
|
}))
|
|
}
|
|
|
|
function validateForm(): string[] {
|
|
const errors: string[] = []
|
|
const hasRequiredDocument =
|
|
Boolean(form.document.invoice_date) && Boolean(form.document.booking_date) && Boolean(form.document.due_date)
|
|
const hasRequiredRecipient =
|
|
Boolean(form.recipient.company.trim()) &&
|
|
Boolean(form.recipient.attention.trim()) &&
|
|
Boolean(form.recipient.address.trim()) &&
|
|
Boolean(form.recipient.telephone.trim()) &&
|
|
Boolean(form.recipient.email.trim())
|
|
const hasRequiredBooking =
|
|
Boolean(form.booking.group_name.trim()) &&
|
|
Boolean(form.booking.arrival_date) &&
|
|
Boolean(form.booking.departure_date)
|
|
const extraBedRate = optionalNumberValue(form.booking.extra_bed_rate)
|
|
const hasInvalidChargeNumber = form.charges.some(
|
|
(charge) =>
|
|
hasInvalidNumericInput(charge.quantity) || hasInvalidNumericInput(charge.rate) || hasInvalidNumericInput(charge.nights),
|
|
)
|
|
const hasInvalidExtraBedRate = hasNumericInputValue(form.booking.extra_bed_rate) && extraBedRate === null
|
|
const hasRequiredCharges =
|
|
form.charges.length > 0 &&
|
|
form.charges.every(
|
|
(charge) =>
|
|
Boolean(charge.description.trim()) &&
|
|
Boolean(charge.room_type.trim()) &&
|
|
hasNumericInputValue(charge.quantity) &&
|
|
hasNumericInputValue(charge.rate) &&
|
|
hasNumericInputValue(charge.nights),
|
|
)
|
|
const hasInvalidQuantityOrNights = form.charges.some((charge) => {
|
|
const quantity = optionalNumberValue(charge.quantity)
|
|
const nights = optionalNumberValue(charge.nights)
|
|
return (quantity !== null && quantity <= 0) || (nights !== null && nights <= 0)
|
|
})
|
|
const hasNegativeRate = form.charges.some((charge) => {
|
|
const rate = optionalNumberValue(charge.rate)
|
|
return rate !== null && rate < 0
|
|
})
|
|
|
|
if (!hasRequiredDocument || !hasRequiredRecipient || !hasRequiredBooking || !hasRequiredCharges) {
|
|
errors.push(t('manualInvoice.errors.requiredFields'))
|
|
}
|
|
if (hasInvalidChargeNumber || hasInvalidExtraBedRate) {
|
|
errors.push(t('manualInvoice.errors.invalidNumber'))
|
|
}
|
|
if (form.recipient.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.recipient.email)) {
|
|
errors.push(t('manualInvoice.errors.invalidEmail'))
|
|
}
|
|
if (form.document.booking_date && form.document.due_date && form.document.due_date < form.document.booking_date) {
|
|
errors.push(t('manualInvoice.errors.dueDateBeforeBookingDate'))
|
|
}
|
|
if (
|
|
form.booking.arrival_date &&
|
|
form.booking.departure_date &&
|
|
form.booking.departure_date <= form.booking.arrival_date
|
|
) {
|
|
errors.push(t('manualInvoice.errors.departureBeforeArrival'))
|
|
}
|
|
if (extraBedRate !== null && extraBedRate < 0) {
|
|
errors.push(t('manualInvoice.errors.extraBedNegative'))
|
|
}
|
|
if (hasInvalidQuantityOrNights) {
|
|
errors.push(t('manualInvoice.errors.chargePositive'))
|
|
}
|
|
if (hasNegativeRate) {
|
|
errors.push(t('manualInvoice.errors.chargeRateNonNegative'))
|
|
}
|
|
return [...new Set(errors)]
|
|
}
|
|
|
|
function collectFieldErrors(): FieldErrorMap {
|
|
const errors: FieldErrorMap = {}
|
|
const positiveNumber = t('manualInvoice.fieldErrors.positiveNumber')
|
|
const nonNegativeNumber = t('manualInvoice.fieldErrors.nonNegativeNumber')
|
|
|
|
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 = validateOptionalNumericField(errors, 'extra-bed-rate', form.booking.extra_bed_rate)
|
|
if (extraBedRate !== null && extraBedRate < 0) {
|
|
errors['extra-bed-rate'] = nonNegativeNumber
|
|
}
|
|
|
|
form.charges.forEach((charge, index) => {
|
|
setRequiredError(errors, chargeFieldKey(index, 'description'), charge.description)
|
|
setRequiredError(errors, chargeFieldKey(index, 'room-type'), charge.room_type)
|
|
const quantity = validateRequiredNumericField(errors, chargeFieldKey(index, 'quantity'), charge.quantity)
|
|
if (quantity !== null && quantity <= 0) {
|
|
errors[chargeFieldKey(index, 'quantity')] = positiveNumber
|
|
}
|
|
const rate = validateRequiredNumericField(errors, chargeFieldKey(index, 'rate'), charge.rate)
|
|
if (rate !== null && rate < 0) {
|
|
errors[chargeFieldKey(index, 'rate')] = nonNegativeNumber
|
|
}
|
|
const nights = validateRequiredNumericField(errors, chargeFieldKey(index, 'nights'), charge.nights)
|
|
if (nights !== null && 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 validateRequiredNumericField(errors: FieldErrorMap, fieldKey: string, value: string | number): number | null {
|
|
if (!hasNumericInputValue(value)) {
|
|
errors[fieldKey] = t('manualInvoice.fieldErrors.required')
|
|
return null
|
|
}
|
|
const parsed = optionalNumberValue(value)
|
|
if (parsed === null) {
|
|
errors[fieldKey] = t('manualInvoice.fieldErrors.invalidNumber')
|
|
return null
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function validateOptionalNumericField(errors: FieldErrorMap, fieldKey: string, value: string | number): number | null {
|
|
if (!hasNumericInputValue(value)) {
|
|
return null
|
|
}
|
|
const parsed = optionalNumberValue(value)
|
|
if (parsed === null) {
|
|
errors[fieldKey] = t('manualInvoice.fieldErrors.invalidNumber')
|
|
return null
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function hasInvalidNumericInput(value: string | number): boolean {
|
|
return hasNumericInputValue(value) && optionalNumberValue(value) === null
|
|
}
|
|
|
|
function hasNumericInputValue(value: string | number): boolean {
|
|
return String(value).trim() !== ''
|
|
}
|
|
|
|
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
|
|
const errorCode = authKey ?? errorCodeFromDetails(error.details)
|
|
const messageKey = `manualInvoice.errors.${errorCode}`
|
|
const message = te(messageKey) ? t(messageKey) : t('manualInvoice.errors.UNKNOWN')
|
|
return {
|
|
message,
|
|
technicalDetails: detailMessages(error.details),
|
|
}
|
|
}
|
|
return {
|
|
message: t('manualInvoice.errors.UNKNOWN'),
|
|
technicalDetails: [],
|
|
}
|
|
}
|
|
|
|
function errorCodeFromDetails(details: unknown): string {
|
|
if (isRecord(details) && typeof details.error_code === 'string') {
|
|
return details.error_code
|
|
}
|
|
return 'UNKNOWN'
|
|
}
|
|
|
|
function detailMessages(details: unknown): string[] {
|
|
if (!isRecord(details)) {
|
|
return []
|
|
}
|
|
const messages: string[] = []
|
|
if (typeof details.message === 'string') {
|
|
messages.push(details.message)
|
|
}
|
|
const rawDetails = details.details
|
|
if (Array.isArray(rawDetails)) {
|
|
messages.push(...rawDetails.filter((item): item is string => typeof item === 'string'))
|
|
}
|
|
return [...new Set(messages)]
|
|
}
|
|
|
|
function applyContactSeed(contactId: string): void {
|
|
const contact = recipientContacts.value.find((item) => item.contact_id === contactId)
|
|
if (!contact) {
|
|
return
|
|
}
|
|
form.recipient.attention = contact.attention
|
|
form.recipient.address = contact.address
|
|
form.recipient.telephone = contact.telephone
|
|
form.recipient.email = contact.email
|
|
}
|
|
|
|
function applyManualCompanySelection(): void {
|
|
form.recipient.company = ''
|
|
if (form.recipient.contact_id !== MANUAL_SELECTION) {
|
|
form.recipient.contact_id = MANUAL_SELECTION
|
|
}
|
|
clearRecipientContactProfile()
|
|
}
|
|
|
|
function clearRecipientContactProfile(): void {
|
|
form.recipient.attention = ''
|
|
form.recipient.address = ''
|
|
form.recipient.telephone = ''
|
|
form.recipient.email = ''
|
|
}
|
|
|
|
function markDocumentDatesEdited(): void {
|
|
documentDatesEdited.value = true
|
|
}
|
|
|
|
function markPrimaryNightsEdited(): void {
|
|
primaryNightsEdited.value = true
|
|
}
|
|
|
|
function markChargeNightsEdited(index: number): void {
|
|
if (index === 0) {
|
|
markPrimaryNightsEdited()
|
|
}
|
|
}
|
|
|
|
function derivePrimaryNightsFromStayDates(): void {
|
|
if (primaryNightsEdited.value) {
|
|
return
|
|
}
|
|
const nights = stayNights(form.booking.arrival_date, form.booking.departure_date)
|
|
if (nights === null) {
|
|
return
|
|
}
|
|
primaryCharge.value.nights = String(nights)
|
|
}
|
|
|
|
function applyDefaultDocumentDates(timeZone: string): void {
|
|
const dates = createDefaultDocumentDates(timeZone)
|
|
form.document.invoice_date = dates.invoiceDate
|
|
form.document.booking_date = dates.bookingDate
|
|
form.document.due_date = dates.dueDate
|
|
}
|
|
|
|
function triggerBrowserDownload(objectUrl: string, fileName: string): void {
|
|
const anchor = document.createElement('a')
|
|
anchor.href = objectUrl
|
|
anchor.download = fileName
|
|
anchor.rel = 'noreferrer'
|
|
anchor.style.display = 'none'
|
|
document.body.appendChild(anchor)
|
|
anchor.click()
|
|
anchor.remove()
|
|
}
|
|
|
|
function createPdfFileName(invoiceGenerationId: string): string {
|
|
const safeId = invoiceGenerationId.replace(/[^a-zA-Z0-9_.-]/g, '-')
|
|
return `proforma-invoice-${safeId}.pdf`
|
|
}
|
|
|
|
function numberValue(value: string | number): number {
|
|
return parseNumericInput(value) ?? 0
|
|
}
|
|
|
|
function optionalNumberValue(value: string | number): number | null {
|
|
return parseNumericInput(value)
|
|
}
|
|
|
|
function parseNumericInput(value: string | number): number | null {
|
|
const trimmed = String(value).replace(/,/g, '').trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
const parsed = Number(trimmed)
|
|
return Number.isFinite(parsed) ? parsed : null
|
|
}
|
|
|
|
function trimToNull(value: string): string | null {
|
|
const trimmed = value.trim()
|
|
return trimmed || null
|
|
}
|
|
|
|
function directoryCodeOrNull(value: string): string | null {
|
|
if (!value || value === MANUAL_SELECTION) {
|
|
return null
|
|
}
|
|
return value
|
|
}
|
|
|
|
function formatMoney(value: number, currency: string): string {
|
|
return new Intl.NumberFormat(locale.value, {
|
|
style: 'currency',
|
|
currency,
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(value)
|
|
}
|
|
|
|
function formatGenerationStatus(status: string): string {
|
|
const key = `manualInvoice.statuses.${status}`
|
|
return te(key) ? t(key) : status
|
|
}
|
|
|
|
function createDefaultDocumentDates(timeZone: string): {
|
|
invoiceDate: string
|
|
bookingDate: string
|
|
dueDate: string
|
|
} {
|
|
const businessDate = dateInputValueInTimeZone(new Date(), timeZone)
|
|
return {
|
|
invoiceDate: businessDate,
|
|
bookingDate: businessDate,
|
|
dueDate: addDaysToDateInputValue(businessDate, 7),
|
|
}
|
|
}
|
|
|
|
function resolveHotelTimeZone(): string {
|
|
return authStore.selectedHotel?.time_zone || reservationTimeZone
|
|
}
|
|
|
|
function dateInputValueInTimeZone(date: Date, timeZone: string): string {
|
|
try {
|
|
return datePartsToInputValue(formatDateParts(date, timeZone))
|
|
} catch {
|
|
return datePartsToInputValue(formatDateParts(date, reservationTimeZone))
|
|
}
|
|
}
|
|
|
|
function addDaysToDateInputValue(dateValue: string, days: number): string {
|
|
const [year = 0, month = 1, day = 1] = dateValue.split('-').map((part) => Number(part))
|
|
const nextDate = new Date(Date.UTC(year, month - 1, day + days))
|
|
return [
|
|
String(nextDate.getUTCFullYear()),
|
|
String(nextDate.getUTCMonth() + 1).padStart(2, '0'),
|
|
String(nextDate.getUTCDate()).padStart(2, '0'),
|
|
].join('-')
|
|
}
|
|
|
|
function stayNights(arrivalDate: string, departureDate: string): number | null {
|
|
if (!arrivalDate || !departureDate || departureDate <= arrivalDate) {
|
|
return null
|
|
}
|
|
const arrival = dateInputValueToUtcDate(arrivalDate)
|
|
const departure = dateInputValueToUtcDate(departureDate)
|
|
if (!arrival || !departure) {
|
|
return null
|
|
}
|
|
const nights = Math.round((departure.getTime() - arrival.getTime()) / 86_400_000)
|
|
return nights > 0 ? nights : null
|
|
}
|
|
|
|
function dateInputValueToUtcDate(dateValue: string): Date | null {
|
|
const [year = 0, month = 0, day = 0] = dateValue.split('-').map((part) => Number(part))
|
|
if (!year || !month || !day) {
|
|
return null
|
|
}
|
|
const date = new Date(Date.UTC(year, month - 1, day))
|
|
if (date.getUTCFullYear() !== year || date.getUTCMonth() + 1 !== month || date.getUTCDate() !== day) {
|
|
return null
|
|
}
|
|
return date
|
|
}
|
|
|
|
function formatDateParts(date: Date, timeZone: string): Intl.DateTimeFormatPart[] {
|
|
return new Intl.DateTimeFormat('en-GB', {
|
|
timeZone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).formatToParts(date)
|
|
}
|
|
|
|
function datePartsToInputValue(parts: Intl.DateTimeFormatPart[]): string {
|
|
const valueByType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
|
const year = valueByType.year
|
|
const month = valueByType.month
|
|
const day = valueByType.day
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.invoice-page {
|
|
width: min(100%, 1244px);
|
|
margin: 0 auto;
|
|
color: var(--th-color-navy-950);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.invoice-page__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 28px;
|
|
padding: 6px 2px 4px;
|
|
}
|
|
|
|
.invoice-page__title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.invoice-page__title h1 {
|
|
margin: 0;
|
|
color: var(--th-color-navy-950);
|
|
font-size: clamp(24px, 3vw, 34px);
|
|
line-height: 1.15;
|
|
}
|
|
|
|
.source-summary {
|
|
display: grid;
|
|
justify-items: end;
|
|
gap: 3px;
|
|
flex: 0 0 auto;
|
|
text-align: right;
|
|
}
|
|
|
|
.source-summary span,
|
|
.source-summary small {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 10px;
|
|
}
|
|
|
|
.source-summary strong {
|
|
max-width: 360px;
|
|
overflow-wrap: anywhere;
|
|
color: var(--th-color-navy-950);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.invoice-form,
|
|
.invoice-form-column {
|
|
display: grid;
|
|
gap: 18px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.invoice-form {
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.section-card {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
border: 1px solid var(--th-color-border);
|
|
border-radius: 8px;
|
|
background: var(--th-color-white);
|
|
box-shadow: var(--th-shadow-panel);
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 18px;
|
|
padding: 20px 22px 18px;
|
|
}
|
|
|
|
.section-title,
|
|
.section-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.section-index {
|
|
color: var(--th-color-blue-600);
|
|
font-size: 11px;
|
|
font-weight: 900;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.section-title h2 {
|
|
margin: 0;
|
|
color: var(--th-color-navy-950);
|
|
font-size: 17px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.currency-badge {
|
|
border: 1px solid var(--th-color-border);
|
|
border-radius: 999px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 10px;
|
|
font-weight: 900;
|
|
padding: 6px 9px;
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 18px;
|
|
min-width: 0;
|
|
border-top: 1px solid var(--th-color-border);
|
|
padding: 20px 22px 24px;
|
|
}
|
|
|
|
.field {
|
|
display: grid;
|
|
align-content: start;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.field--wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.field__heading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
min-height: 22px;
|
|
}
|
|
|
|
.field__heading label,
|
|
.field-label {
|
|
color: var(--th-color-slate-600);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.field input,
|
|
.field select,
|
|
.field textarea,
|
|
.charge-grid input {
|
|
width: 100%;
|
|
min-width: 0;
|
|
min-height: 40px;
|
|
border: 1px solid #b9c7dc;
|
|
border-radius: 6px;
|
|
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;
|
|
}
|
|
|
|
.field textarea {
|
|
min-height: 88px;
|
|
resize: vertical;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.field input:hover,
|
|
.field select:hover,
|
|
.field textarea:hover,
|
|
.charge-grid input:hover {
|
|
border-color: #8fa4c2;
|
|
}
|
|
|
|
.field input:focus-visible,
|
|
.field select:focus-visible,
|
|
.field textarea:focus-visible,
|
|
.charge-grid input:focus-visible,
|
|
.add-button:focus-visible,
|
|
.remove-button:focus-visible,
|
|
.generate-button:focus-visible,
|
|
.primary-link:focus-visible,
|
|
.secondary-link:focus-visible {
|
|
outline: 3px solid rgb(18 103 255 / 24%);
|
|
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%);
|
|
}
|
|
|
|
.number-control input.is-invalid + .number-suffix {
|
|
border-color: #e11d48;
|
|
background: #fff1f3;
|
|
color: #be123c;
|
|
}
|
|
|
|
.field-label--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;
|
|
}
|
|
|
|
.directory-control--manual {
|
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
|
align-items: start;
|
|
gap: 10px;
|
|
}
|
|
|
|
.date-control {
|
|
min-width: 0;
|
|
}
|
|
|
|
.number-control {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
min-width: 0;
|
|
}
|
|
|
|
.number-control input {
|
|
border-radius: 6px 0 0 6px;
|
|
}
|
|
|
|
.number-suffix {
|
|
display: grid;
|
|
align-content: center;
|
|
border: 1px solid var(--th-color-border);
|
|
border-left: 0;
|
|
border-radius: 0 6px 6px 0;
|
|
background: var(--th-color-slate-50);
|
|
padding: 5px 10px;
|
|
}
|
|
|
|
.number-suffix {
|
|
place-items: center;
|
|
min-width: 62px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.room-rate-control {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
align-items: start;
|
|
gap: 10px;
|
|
}
|
|
|
|
.room-rate-control label {
|
|
display: grid;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.add-button,
|
|
.remove-button,
|
|
.generate-button,
|
|
.primary-link,
|
|
.secondary-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 7px;
|
|
border-radius: 6px;
|
|
font-weight: 800;
|
|
text-decoration: none;
|
|
transition: border-color 180ms ease, background-color 180ms ease, color 180ms ease, box-shadow 180ms ease;
|
|
}
|
|
|
|
.add-button,
|
|
.remove-button {
|
|
min-height: 34px;
|
|
font-size: 11px;
|
|
padding: 0 11px;
|
|
}
|
|
|
|
.add-button {
|
|
border: 1px solid var(--th-color-blue-600);
|
|
background: var(--th-color-blue-600);
|
|
color: var(--th-color-white);
|
|
}
|
|
|
|
.add-button:hover {
|
|
background: #0759e8;
|
|
}
|
|
|
|
.remove-button {
|
|
border: 1px solid var(--th-color-border);
|
|
background: var(--th-color-white);
|
|
color: var(--th-color-slate-500);
|
|
}
|
|
|
|
.remove-button:hover:not(:disabled) {
|
|
border-color: #ce2b3f;
|
|
background: #ffecef;
|
|
color: #ce2b3f;
|
|
}
|
|
|
|
.add-button:disabled,
|
|
.remove-button:disabled,
|
|
.generate-button:disabled,
|
|
.secondary-link:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.42;
|
|
}
|
|
|
|
.charge-lines {
|
|
border-top: 1px solid var(--th-color-border);
|
|
}
|
|
|
|
.charge-line {
|
|
padding: 18px 22px 22px;
|
|
}
|
|
|
|
.charge-line + .charge-line {
|
|
border-top: 1px solid var(--th-color-border);
|
|
background: var(--th-color-slate-50);
|
|
}
|
|
|
|
.charge-line__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.charge-line__header strong {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.charge-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(180px, 2fr) minmax(145px, 1.4fr) repeat(3, minmax(88px, 0.8fr)) minmax(120px, 0.9fr);
|
|
align-items: start;
|
|
gap: 14px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.charge-amount {
|
|
display: grid;
|
|
align-content: start;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.charge-amount > span {
|
|
min-height: 22px;
|
|
color: var(--th-color-slate-600);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.charge-amount output {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
min-height: 40px;
|
|
overflow-wrap: anywhere;
|
|
border: 1px solid var(--th-color-border);
|
|
border-radius: 6px;
|
|
background: var(--th-color-slate-50);
|
|
color: var(--th-color-navy-950);
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
padding: 9px 11px;
|
|
}
|
|
|
|
.results {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
border-top: 1px solid var(--th-color-border);
|
|
background: var(--th-color-white);
|
|
padding: 8px 22px 14px;
|
|
}
|
|
|
|
.results dl {
|
|
width: min(100%, 390px);
|
|
margin: 0;
|
|
}
|
|
|
|
.results dl > div,
|
|
.result-list div {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
border-bottom: 1px solid var(--th-color-slate-100);
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.results dl > div:last-child,
|
|
.result-list div:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.results dt,
|
|
.result-list dt {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.results dd,
|
|
.result-list dd {
|
|
margin: 0;
|
|
color: var(--th-color-navy-950);
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
text-align: right;
|
|
}
|
|
|
|
.results .total-row dt,
|
|
.results .total-row dd {
|
|
color: var(--th-color-blue-600);
|
|
}
|
|
|
|
.action-bar {
|
|
position: sticky;
|
|
bottom: 16px;
|
|
z-index: 8;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
margin-top: 18px;
|
|
border: 1px solid var(--th-color-border);
|
|
border-radius: 8px;
|
|
background: rgb(255 255 255 / 96%);
|
|
box-shadow: 0 18px 45px rgb(11 31 69 / 16%);
|
|
padding: 12px 14px;
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
.generate-button {
|
|
min-height: 44px;
|
|
border: 0;
|
|
background: var(--th-color-blue-600);
|
|
color: var(--th-color-white);
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
padding: 0 18px;
|
|
}
|
|
|
|
.generate-button:hover:not(:disabled) {
|
|
background: #0759e8;
|
|
box-shadow: 0 8px 18px rgb(18 103 255 / 24%);
|
|
}
|
|
|
|
.invoice-alert {
|
|
padding: 18px 22px;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.invoice-alert--error {
|
|
border-color: #fecdd3;
|
|
background: #fff1f2;
|
|
color: #be123c;
|
|
}
|
|
|
|
.invoice-alert ul {
|
|
margin: 8px 0 0;
|
|
padding-left: 18px;
|
|
}
|
|
|
|
.technical-details {
|
|
border-top: 1px solid rgb(190 18 60 / 16%);
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.technical-details summary {
|
|
cursor: pointer;
|
|
color: #9f1239;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.technical-details li {
|
|
word-break: break-word;
|
|
}
|
|
|
|
.result-card {
|
|
padding-bottom: 20px;
|
|
}
|
|
|
|
.result-list {
|
|
display: grid;
|
|
gap: 0;
|
|
margin: 0;
|
|
padding: 0 22px;
|
|
}
|
|
|
|
.result-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
margin: 18px 22px 0;
|
|
}
|
|
|
|
.primary-link {
|
|
border: 1px solid var(--th-color-blue-600);
|
|
background: var(--th-color-blue-600);
|
|
color: var(--th-color-white);
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.secondary-link {
|
|
border: 1px solid var(--th-color-border);
|
|
background: var(--th-color-white);
|
|
color: var(--th-color-blue-600);
|
|
cursor: pointer;
|
|
font: inherit;
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.muted-text {
|
|
margin: 12px 22px 0;
|
|
color: var(--th-color-slate-500);
|
|
}
|
|
|
|
.visually-hidden {
|
|
position: absolute !important;
|
|
width: 1px !important;
|
|
height: 1px !important;
|
|
overflow: hidden !important;
|
|
clip: rect(0, 0, 0, 0) !important;
|
|
white-space: nowrap !important;
|
|
clip-path: inset(50%) !important;
|
|
}
|
|
|
|
@media (max-width: 1050px) {
|
|
.charge-grid {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
|
|
.charge-field--description,
|
|
.charge-field--room-type {
|
|
grid-column: span 2;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 700px) {
|
|
.invoice-page__header,
|
|
.action-bar {
|
|
align-items: stretch;
|
|
display: grid;
|
|
}
|
|
|
|
.invoice-page__title {
|
|
align-items: flex-start;
|
|
display: grid;
|
|
}
|
|
|
|
.source-summary {
|
|
justify-items: start;
|
|
text-align: left;
|
|
}
|
|
|
|
.form-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
padding: 18px 16px 20px;
|
|
}
|
|
|
|
.field--wide {
|
|
grid-column: auto;
|
|
}
|
|
|
|
.section-header {
|
|
align-items: stretch;
|
|
display: grid;
|
|
padding: 18px 16px 14px;
|
|
}
|
|
|
|
.section-actions {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.room-rate-control {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.charge-line {
|
|
padding: 16px;
|
|
}
|
|
|
|
.charge-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.charge-field--description,
|
|
.charge-field--room-type {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.results {
|
|
padding: 8px 16px 14px;
|
|
}
|
|
|
|
.action-bar {
|
|
position: static;
|
|
padding: 14px;
|
|
}
|
|
|
|
.generate-button {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 560px) {
|
|
.directory-control--manual {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.number-control {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.number-control input {
|
|
border-radius: 6px 6px 0 0;
|
|
}
|
|
|
|
.number-suffix {
|
|
min-height: 42px;
|
|
border-top: 0;
|
|
border-left: 1px solid var(--th-color-border);
|
|
border-radius: 0 0 6px 6px;
|
|
}
|
|
|
|
.charge-line__header {
|
|
align-items: stretch;
|
|
display: grid;
|
|
}
|
|
|
|
.remove-button {
|
|
width: 100%;
|
|
}
|
|
|
|
.charge-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.charge-field--description,
|
|
.charge-field--room-type {
|
|
grid-column: auto;
|
|
}
|
|
}
|
|
</style>
|