新增房表生成前端页面

This commit is contained in:
andy
2026-07-18 12:58:01 +07:00
parent d3a52e7605
commit 0715e8334f
16 changed files with 1694 additions and 5 deletions

View File

@@ -0,0 +1,862 @@
<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>
<label class="field">
<span class="field-label field-label--required">{{ t('roomingList.arrival') }}</span>
<input
v-model.trim="form.arrival"
data-testid="rooming-list-arrival"
type="date"
:lang="locale"
:class="validationClass('arrival')"
v-bind="validationAria('arrival')"
required
>
<span
v-if="fieldError('arrival')"
:id="fieldErrorId('arrival')"
class="field-error"
>
{{ fieldError('arrival') }}
</span>
</label>
<label class="field">
<span class="field-label field-label--required">{{ t('roomingList.departure') }}</span>
<input
v-model.trim="form.departure"
data-testid="rooming-list-departure"
type="date"
:lang="locale"
:class="validationClass('departure')"
v-bind="validationAria('departure')"
required
>
<span
v-if="fieldError('departure')"
:id="fieldErrorId('departure')"
class="field-error"
>
{{ fieldError('departure') }}
</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
v-for="field in optionalFields"
:key="field.key"
class="field"
>
<span class="field-label">{{ t(field.labelKey) }}</span>
<input
v-model.trim="form[field.key]"
:data-testid="field.testId"
:type="field.type"
:min="field.min"
:step="field.step"
autocomplete="off"
:class="validationClass(field.key)"
v-bind="validationAria(field.key)"
>
<span
v-if="fieldError(field.key)"
:id="fieldErrorId(field.key)"
class="field-error"
>
{{ fieldError(field.key) }}
</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'
| 'arrival'
| 'departure'
| 'roomType'
| 'title'
| 'rateCode'
| 'adults'
| 'children'
| 'paymentType'
| 'vip'
| 'nationality'
| 'email'
| 'idType'
| 'idNumber'
interface OptionalFieldDefinition {
key: Exclude<RoomingListFieldKey, 'file' | 'peoplePerRoom' | 'arrival' | 'departure' | 'roomType'>
labelKey: string
testId: string
type: 'text' | 'email' | 'number'
min?: string
step?: string
}
const { t, te, locale } = useI18n()
const authStore = useAuthStore()
const form = reactive({
file: null as File | null,
peoplePerRoom: '',
arrival: '',
departure: '',
roomType: '',
title: '',
rateCode: '',
adults: '',
children: '',
paymentType: '',
vip: '',
nationality: '',
email: '',
idType: '',
idNumber: '',
})
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 optionalFields: OptionalFieldDefinition[] = [
{ key: 'title', labelKey: 'roomingList.titleColumn', testId: 'rooming-list-title', type: 'text' },
{ key: 'rateCode', labelKey: 'roomingList.rateCode', testId: 'rooming-list-rate-code', type: 'text' },
{ key: 'adults', labelKey: 'roomingList.adults', testId: 'rooming-list-adults', type: 'number', min: '0', step: '1' },
{
key: 'children',
labelKey: 'roomingList.children',
testId: 'rooming-list-children',
type: 'number',
min: '0',
step: '1',
},
{ key: 'paymentType', labelKey: 'roomingList.paymentType', testId: 'rooming-list-payment-type', type: 'text' },
{ key: 'vip', labelKey: 'roomingList.vip', testId: 'rooming-list-vip', type: 'text' },
{ key: 'nationality', labelKey: 'roomingList.nationality', testId: 'rooming-list-nationality', type: 'text' },
{ key: 'email', labelKey: 'roomingList.email', testId: 'rooming-list-email', type: 'email' },
{ key: 'idType', labelKey: 'roomingList.idType', testId: 'rooming-list-id-type', type: 'text' },
{ key: 'idNumber', labelKey: 'roomingList.idNumber', testId: 'rooming-list-id-number', type: 'text' },
]
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),
arrival: form.arrival,
departure: form.departure,
roomType: form.roomType,
title: form.title,
rateCode: form.rateCode,
adults: optionalIntegerValue(form.adults),
children: optionalIntegerValue(form.children),
paymentType: form.paymentType,
vip: form.vip,
nationality: form.nationality,
email: form.email,
idType: form.idType,
idNumber: form.idNumber,
}
}
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 (!isValidDateValue(form.arrival)) {
errors.arrival = form.arrival ? t('roomingList.fieldErrors.dateFormat') : t('roomingList.fieldErrors.arrivalRequired')
}
if (!isValidDateValue(form.departure)) {
errors.departure = form.departure
? t('roomingList.fieldErrors.dateFormat')
: t('roomingList.fieldErrors.departureRequired')
}
if (isValidDateValue(form.arrival) && isValidDateValue(form.departure) && form.departure <= form.arrival) {
errors.departure = t('roomingList.fieldErrors.departureAfterArrival')
}
if (!form.roomType.trim()) {
errors.roomType = t('roomingList.fieldErrors.roomTypeRequired')
}
if (form.adults && !isNonNegativeInteger(form.adults)) {
errors.adults = t('roomingList.fieldErrors.nonNegativeInteger')
}
if (form.children && !isNonNegativeInteger(form.children)) {
errors.children = t('roomingList.fieldErrors.nonNegativeInteger')
}
if (form.email && !isValidEmail(form.email)) {
errors.email = t('roomingList.fieldErrors.invalidEmail')
}
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 optionalIntegerValue(value: string): number | null {
const normalized = normalizeInputText(value)
if (!normalized) {
return null
}
return Number(normalized)
}
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 isNonNegativeInteger(value: unknown): boolean {
return /^(0|[1-9]\d*)$/.test(normalizeInputText(value))
}
function isValidDateValue(value: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
const yearText = match?.[1]
const monthText = match?.[2]
const dayText = match?.[3]
if (!yearText || !monthText || !dayText) {
return false
}
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
const date = new Date(year, month - 1, day)
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
}
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.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 {
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 {
border-color: #8fa4c2;
}
.field input: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,
.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>