760 lines
18 KiB
Vue
760 lines
18 KiB
Vue
<template>
|
|
<main
|
|
class="rooming-list-page"
|
|
data-testid="rooming-list-page"
|
|
>
|
|
<header class="page-header">
|
|
<div>
|
|
<p class="eyebrow">
|
|
{{ t('roomingList.eyebrow') }}
|
|
</p>
|
|
<h1>{{ t('roomingList.title') }}</h1>
|
|
<p class="subtitle">
|
|
{{ t('roomingList.subtitle') }}
|
|
</p>
|
|
</div>
|
|
<div class="hotel-context">
|
|
<span>{{ t('roomingList.currentHotel') }}</span>
|
|
<strong>{{ currentHotelLabel }}</strong>
|
|
</div>
|
|
</header>
|
|
|
|
<form
|
|
class="generation-form"
|
|
@submit.prevent="submitGeneration"
|
|
>
|
|
<section
|
|
class="section-card"
|
|
aria-labelledby="rooming-list-source-title"
|
|
>
|
|
<header class="section-header">
|
|
<div class="section-title">
|
|
<span class="section-index">01</span>
|
|
<h2 id="rooming-list-source-title">
|
|
{{ t('roomingList.sourceSection') }}
|
|
</h2>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="form-grid">
|
|
<div class="field field--wide">
|
|
<label
|
|
for="rooming-list-file"
|
|
class="field-label field-label--required"
|
|
>
|
|
{{ t('roomingList.sourceFile') }}
|
|
</label>
|
|
<label
|
|
class="file-drop"
|
|
:class="{ 'is-invalid': Boolean(fieldError('file')) }"
|
|
>
|
|
<input
|
|
id="rooming-list-file"
|
|
data-testid="rooming-list-file"
|
|
type="file"
|
|
accept=".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
@change="chooseSourceFile"
|
|
>
|
|
<i
|
|
class="pi pi-file-excel"
|
|
aria-hidden="true"
|
|
/>
|
|
<span>
|
|
<strong>{{ sourceFileLabel }}</strong>
|
|
<small>{{ t('roomingList.sourceFileHint') }}</small>
|
|
</span>
|
|
</label>
|
|
<p
|
|
v-if="fieldError('file')"
|
|
:id="fieldErrorId('file')"
|
|
class="field-error"
|
|
data-testid="rooming-list-file-error"
|
|
>
|
|
{{ fieldError('file') }}
|
|
</p>
|
|
</div>
|
|
|
|
<label class="field">
|
|
<span class="field-label field-label--required">{{ t('roomingList.peoplePerRoom') }}</span>
|
|
<input
|
|
v-model.trim="form.peoplePerRoom"
|
|
data-testid="rooming-list-people-per-room"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
inputmode="numeric"
|
|
:class="validationClass('peoplePerRoom')"
|
|
v-bind="validationAria('peoplePerRoom')"
|
|
required
|
|
>
|
|
<span
|
|
v-if="fieldError('peoplePerRoom')"
|
|
:id="fieldErrorId('peoplePerRoom')"
|
|
class="field-error"
|
|
>
|
|
{{ fieldError('peoplePerRoom') }}
|
|
</span>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span class="field-label field-label--required">{{ t('roomingList.roomType') }}</span>
|
|
<input
|
|
v-model.trim="form.roomType"
|
|
data-testid="rooming-list-room-type"
|
|
type="text"
|
|
autocomplete="off"
|
|
:placeholder="t('roomingList.roomTypePlaceholder')"
|
|
:class="validationClass('roomType')"
|
|
v-bind="validationAria('roomType')"
|
|
required
|
|
>
|
|
<span
|
|
v-if="fieldError('roomType')"
|
|
:id="fieldErrorId('roomType')"
|
|
class="field-error"
|
|
>
|
|
{{ fieldError('roomType') }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
class="section-card"
|
|
aria-labelledby="rooming-list-target-title"
|
|
>
|
|
<header class="section-header">
|
|
<div class="section-title">
|
|
<span class="section-index">02</span>
|
|
<h2 id="rooming-list-target-title">
|
|
{{ t('roomingList.targetSection') }}
|
|
</h2>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="form-grid">
|
|
<label class="field">
|
|
<span class="field-label">{{ t('roomingList.paymentType') }}</span>
|
|
<select
|
|
v-model="form.paymentType"
|
|
data-testid="rooming-list-payment-type"
|
|
:class="validationClass('paymentType')"
|
|
v-bind="validationAria('paymentType')"
|
|
>
|
|
<option value="BTQR">
|
|
{{ t('roomingList.paymentTypeBtqr') }}
|
|
</option>
|
|
<option value="CA">
|
|
{{ t('roomingList.paymentTypeCa') }}
|
|
</option>
|
|
</select>
|
|
<span
|
|
v-if="fieldError('paymentType')"
|
|
:id="fieldErrorId('paymentType')"
|
|
class="field-error"
|
|
>
|
|
{{ fieldError('paymentType') }}
|
|
</span>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span class="field-label field-label--required">{{ t('roomingList.nationality') }}</span>
|
|
<select
|
|
v-model="form.nationality"
|
|
data-testid="rooming-list-nationality"
|
|
:class="validationClass('nationality')"
|
|
v-bind="validationAria('nationality')"
|
|
required
|
|
>
|
|
<option
|
|
disabled
|
|
value=""
|
|
>
|
|
{{ t('roomingList.nationalityPlaceholder') }}
|
|
</option>
|
|
<option value="KR">
|
|
{{ t('roomingList.nationalityKr') }}
|
|
</option>
|
|
<option value="CHN">
|
|
{{ t('roomingList.nationalityChn') }}
|
|
</option>
|
|
</select>
|
|
<span
|
|
v-if="fieldError('nationality')"
|
|
:id="fieldErrorId('nationality')"
|
|
class="field-error"
|
|
>
|
|
{{ fieldError('nationality') }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-if="messages.length || detailMessages.length || generatedFileName"
|
|
class="section-card result-card"
|
|
:class="`result-card--${alertTone}`"
|
|
aria-live="polite"
|
|
>
|
|
<header class="section-header">
|
|
<div class="section-title">
|
|
<span class="section-index">03</span>
|
|
<h2>{{ alertTitle }}</h2>
|
|
</div>
|
|
</header>
|
|
<div class="result-body">
|
|
<p
|
|
v-for="message in messages"
|
|
:key="message"
|
|
>
|
|
{{ message }}
|
|
</p>
|
|
<dl v-if="generatedFileName">
|
|
<div>
|
|
<dt>{{ t('roomingList.downloadedFile') }}</dt>
|
|
<dd>{{ generatedFileName }}</dd>
|
|
</div>
|
|
</dl>
|
|
<ul
|
|
v-if="detailMessages.length"
|
|
class="detail-list"
|
|
>
|
|
<li
|
|
v-for="detail in detailMessages"
|
|
:key="detail"
|
|
>
|
|
{{ detail }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="form-actions">
|
|
<button
|
|
type="submit"
|
|
class="generate-button"
|
|
data-testid="rooming-list-submit"
|
|
:disabled="submitting"
|
|
@click.prevent="submitGeneration"
|
|
>
|
|
<i
|
|
class="pi pi-download"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ submitting ? t('roomingList.generating') : t('roomingList.generate') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, reactive, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import {
|
|
generateReservationRoomingListExcel,
|
|
RoomingListGenerationError,
|
|
} from '@/services/roomingListService'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import type { RoomingListGenerationInput } from '@/types/roomingList'
|
|
|
|
type FieldErrors = Partial<Record<RoomingListFieldKey, string>>
|
|
type AlertTone = 'success' | 'error'
|
|
type RoomingListFieldKey =
|
|
| 'file'
|
|
| 'peoplePerRoom'
|
|
| 'roomType'
|
|
| 'paymentType'
|
|
| 'nationality'
|
|
|
|
const allowedPaymentTypes = new Set(['BTQR', 'CA'])
|
|
const allowedNationalities = new Set(['KR', 'CHN'])
|
|
|
|
const { t, te } = useI18n()
|
|
const authStore = useAuthStore()
|
|
|
|
const form = reactive({
|
|
file: null as File | null,
|
|
peoplePerRoom: '',
|
|
roomType: '',
|
|
paymentType: 'BTQR',
|
|
nationality: '',
|
|
})
|
|
|
|
const submitting = ref(false)
|
|
const validationAttempted = ref(false)
|
|
const messages = ref<string[]>([])
|
|
const detailMessages = ref<string[]>([])
|
|
const generatedFileName = ref('')
|
|
const alertTone = ref<AlertTone>('error')
|
|
|
|
const currentHotelLabel = computed(() => authStore.selectedHotel?.hotel_name || authStore.selectedHotelId || '-')
|
|
const sourceFileLabel = computed(() => form.file?.name || t('roomingList.noFile'))
|
|
const alertTitle = computed(() =>
|
|
alertTone.value === 'success' ? t('roomingList.resultSuccess') : t('roomingList.validationTitle'),
|
|
)
|
|
const fieldErrors = computed<FieldErrors>(() => (validationAttempted.value ? collectFieldErrors() : {}))
|
|
|
|
function chooseSourceFile(event: Event): void {
|
|
const input = event.target
|
|
if (!(input instanceof HTMLInputElement)) {
|
|
return
|
|
}
|
|
form.file = input.files?.[0] ?? null
|
|
clearResult()
|
|
}
|
|
|
|
async function submitGeneration(): Promise<void> {
|
|
if (submitting.value) {
|
|
return
|
|
}
|
|
validationAttempted.value = true
|
|
clearResult()
|
|
const errors = collectFieldErrors()
|
|
if (Object.keys(errors).length > 0) {
|
|
messages.value = [t('roomingList.errors.FRONTEND_VALIDATION')]
|
|
detailMessages.value = Object.values(errors)
|
|
return
|
|
}
|
|
if (!form.file) {
|
|
return
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
const result = await generateReservationRoomingListExcel(buildGenerationInput(form.file))
|
|
downloadBlob(result.blob, result.fileName)
|
|
alertTone.value = 'success'
|
|
generatedFileName.value = result.fileName
|
|
messages.value = [t('roomingList.resultDownloaded')]
|
|
} catch (error) {
|
|
handleGenerationError(error)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function buildGenerationInput(file: File): RoomingListGenerationInput {
|
|
return {
|
|
file,
|
|
hotelId: authStore.selectedHotelId,
|
|
peoplePerRoom: Number(form.peoplePerRoom),
|
|
roomType: form.roomType,
|
|
paymentType: form.paymentType,
|
|
nationality: form.nationality,
|
|
}
|
|
}
|
|
|
|
function handleGenerationError(error: unknown): void {
|
|
alertTone.value = 'error'
|
|
if (error instanceof RoomingListGenerationError) {
|
|
const messageKey = `roomingList.errors.${error.errorCode}`
|
|
messages.value = [te(messageKey) ? t(messageKey) : error.message || t('roomingList.errors.UNKNOWN')]
|
|
detailMessages.value = error.details
|
|
return
|
|
}
|
|
messages.value = [t('roomingList.errors.UNKNOWN')]
|
|
detailMessages.value = []
|
|
}
|
|
|
|
function downloadBlob(blob: Blob, fileName: string): void {
|
|
const url = URL.createObjectURL(blob)
|
|
const revokeObjectUrl = URL.revokeObjectURL.bind(URL)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = fileName
|
|
document.body.append(link)
|
|
link.click()
|
|
link.remove()
|
|
window.setTimeout(() => revokeObjectUrl(url), 0)
|
|
}
|
|
|
|
function clearResult(): void {
|
|
messages.value = []
|
|
detailMessages.value = []
|
|
generatedFileName.value = ''
|
|
alertTone.value = 'error'
|
|
}
|
|
|
|
function collectFieldErrors(): FieldErrors {
|
|
const errors: FieldErrors = {}
|
|
if (!form.file) {
|
|
errors.file = t('roomingList.fieldErrors.fileRequired')
|
|
} else if (!isExcelFileName(form.file.name)) {
|
|
errors.file = t('roomingList.fieldErrors.fileType')
|
|
}
|
|
if (!isPositiveInteger(form.peoplePerRoom)) {
|
|
errors.peoplePerRoom = t('roomingList.fieldErrors.positiveInteger')
|
|
}
|
|
if (!form.roomType.trim()) {
|
|
errors.roomType = t('roomingList.fieldErrors.roomTypeRequired')
|
|
}
|
|
if (!form.paymentType.trim()) {
|
|
errors.paymentType = t('roomingList.fieldErrors.paymentTypeRequired')
|
|
} else if (!allowedPaymentTypes.has(form.paymentType.trim())) {
|
|
errors.paymentType = t('roomingList.fieldErrors.paymentTypeAllowed')
|
|
}
|
|
if (!form.nationality.trim()) {
|
|
errors.nationality = t('roomingList.fieldErrors.nationalityRequired')
|
|
} else if (!allowedNationalities.has(form.nationality.trim())) {
|
|
errors.nationality = t('roomingList.fieldErrors.nationalityAllowed')
|
|
}
|
|
return errors
|
|
}
|
|
|
|
function fieldError(fieldKey: RoomingListFieldKey): string {
|
|
return fieldErrors.value[fieldKey] ?? ''
|
|
}
|
|
|
|
function fieldErrorId(fieldKey: RoomingListFieldKey): string {
|
|
return `rooming-list-${fieldKey}-error`
|
|
}
|
|
|
|
function validationClass(fieldKey: RoomingListFieldKey): Record<string, boolean> {
|
|
return {
|
|
'is-invalid': Boolean(fieldError(fieldKey)),
|
|
}
|
|
}
|
|
|
|
function validationAria(fieldKey: RoomingListFieldKey): Record<string, string | undefined> {
|
|
if (!fieldError(fieldKey)) {
|
|
return {
|
|
'aria-invalid': undefined,
|
|
'aria-describedby': undefined,
|
|
}
|
|
}
|
|
return {
|
|
'aria-invalid': 'true',
|
|
'aria-describedby': fieldErrorId(fieldKey),
|
|
}
|
|
}
|
|
|
|
function isExcelFileName(fileName: string): boolean {
|
|
return /\.(xlsx|xls)$/i.test(fileName.trim())
|
|
}
|
|
|
|
function isPositiveInteger(value: unknown): boolean {
|
|
return /^[1-9]\d*$/.test(normalizeInputText(value))
|
|
}
|
|
|
|
function normalizeInputText(value: unknown): string {
|
|
if (typeof value === 'string') {
|
|
return value.trim()
|
|
}
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return String(value)
|
|
}
|
|
return ''
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.rooming-list-page {
|
|
width: min(100%, 1180px);
|
|
margin: 0 auto;
|
|
color: var(--th-color-navy-950);
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 20px;
|
|
align-items: end;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.eyebrow {
|
|
margin: 0 0 6px;
|
|
color: var(--th-color-blue-600);
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
}
|
|
|
|
.page-header h1 {
|
|
margin: 0;
|
|
font-size: 30px;
|
|
line-height: 1.15;
|
|
}
|
|
|
|
.subtitle {
|
|
max-width: 720px;
|
|
margin: 8px 0 0;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}
|
|
|
|
.hotel-context {
|
|
display: grid;
|
|
justify-items: end;
|
|
gap: 4px;
|
|
flex: 0 0 auto;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.hotel-context strong {
|
|
color: var(--th-color-navy-950);
|
|
font-size: 15px;
|
|
}
|
|
|
|
.generation-form {
|
|
display: grid;
|
|
gap: 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;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 18px 20px 16px;
|
|
}
|
|
|
|
.section-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.section-title h2 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.section-index {
|
|
color: var(--th-color-blue-600);
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 18px;
|
|
border-top: 1px solid var(--th-color-border);
|
|
padding: 20px;
|
|
}
|
|
|
|
.field {
|
|
display: grid;
|
|
align-content: start;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.field--wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.field-label {
|
|
color: var(--th-color-slate-600);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.field-label--required::after {
|
|
content: "*";
|
|
margin-left: 4px;
|
|
color: var(--th-color-danger);
|
|
font-weight: 900;
|
|
}
|
|
|
|
.field input,
|
|
.field select {
|
|
width: 100%;
|
|
min-width: 0;
|
|
min-height: 42px;
|
|
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 input:hover,
|
|
.field select:hover {
|
|
border-color: #8fa4c2;
|
|
}
|
|
|
|
.field input:focus-visible,
|
|
.field select:focus-visible,
|
|
.file-drop:focus-within,
|
|
.generate-button:focus-visible {
|
|
outline: 3px solid rgb(18 103 255 / 24%);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.field input.is-invalid,
|
|
.field select.is-invalid,
|
|
.file-drop.is-invalid {
|
|
border-color: var(--th-color-danger);
|
|
background: #fff7f9;
|
|
box-shadow: 0 0 0 3px rgb(206 43 63 / 12%), inset 0 0 0 1px rgb(206 43 63 / 8%);
|
|
}
|
|
|
|
.field-error {
|
|
color: #be123c;
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.file-drop {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-height: 78px;
|
|
border: 1px dashed #9eb2d0;
|
|
border-radius: 8px;
|
|
background: var(--th-color-slate-50);
|
|
padding: 16px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.file-drop input {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.file-drop i {
|
|
color: var(--th-color-success);
|
|
font-size: 24px;
|
|
}
|
|
|
|
.file-drop strong,
|
|
.file-drop small {
|
|
display: block;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.file-drop strong {
|
|
color: var(--th-color-navy-950);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.file-drop small {
|
|
margin-top: 4px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.result-card--success {
|
|
border-color: rgb(21 152 95 / 35%);
|
|
}
|
|
|
|
.result-card--error {
|
|
border-color: rgb(206 43 63 / 25%);
|
|
}
|
|
|
|
.result-body {
|
|
display: grid;
|
|
gap: 12px;
|
|
border-top: 1px solid var(--th-color-border);
|
|
padding: 18px 20px 20px;
|
|
}
|
|
|
|
.result-body p {
|
|
margin: 0;
|
|
color: var(--th-color-slate-700);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.result-body dl {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin: 0;
|
|
}
|
|
|
|
.result-body dl div {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
border-top: 1px solid var(--th-color-border);
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.result-body dt {
|
|
color: var(--th-color-slate-500);
|
|
}
|
|
|
|
.result-body dd {
|
|
margin: 0;
|
|
font-weight: 800;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.detail-list {
|
|
margin: 0;
|
|
padding-left: 18px;
|
|
color: #be123c;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
border: 1px solid var(--th-color-border);
|
|
border-radius: 8px;
|
|
background: var(--th-color-white);
|
|
padding: 14px;
|
|
}
|
|
|
|
.generate-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
min-height: 44px;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
background: var(--th-color-blue-600);
|
|
color: var(--th-color-white);
|
|
font: inherit;
|
|
font-weight: 900;
|
|
padding: 0 18px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.generate-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.62;
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.page-header {
|
|
align-items: start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.hotel-context {
|
|
justify-items: start;
|
|
}
|
|
|
|
.form-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
}
|
|
</style>
|