Files
th-hotel-simple/client/src/components/reservation/ReservationV4TaskCardFieldRenderer.vue
2026-07-21 09:04:13 +07:00

749 lines
20 KiB
Vue

<template>
<div class="v4-field-renderer">
<label
v-for="field in fields"
:key="fieldKey(field)"
class="v4-field"
:class="{ 'v4-field--readonly': !isEditable(field) }"
>
<span class="v4-field__label">
{{ field.display_name }}
<sup v-if="field.required">*</sup>
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
</span>
<select
v-if="isEditable(field) && isSelectField(field)"
class="v4-field__control"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:disabled="selectDisabled(field)"
:value="fieldValue(field)"
@change="updateField(field, $event)"
>
<option value="">{{ t('taskV4.field.selectPlaceholder') }}</option>
<option
v-for="option in fieldOptions(field)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<input
v-else-if="isEditable(field) && isDateField(field)"
class="v4-field__control"
type="date"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isEditable(field) && isCheckboxField(field)"
class="v4-field__control v4-field__control--checkbox"
type="checkbox"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:checked="checkboxValue(field)"
@change="updateCheckboxField(field, $event)"
>
<input
v-else-if="isEditable(field) && isNumberField(field)"
class="v4-field__control"
type="number"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<textarea
v-else-if="isEditable(field) && isTextAreaField(field)"
class="v4-field__control v4-field__control--textarea"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
rows="3"
@input="updateField(field, $event)"
/>
<input
v-else-if="isEditable(field)"
class="v4-field__control"
type="text"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isCheckboxField(field)"
class="v4-field__control v4-field__control--checkbox"
type="checkbox"
:name="fieldKey(field)"
:checked="checkboxValue(field)"
disabled
>
<span
v-else
class="v4-field__static"
>
{{ staticFieldValue(field) }}
</span>
<small
v-if="field.control_hint"
class="v4-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint.key"
class="v4-field__hint"
:class="`v4-field__hint--${hint.tone}`"
>{{ hint.message }}</small>
<small
v-for="error in visibleFieldErrors(field)"
:key="error"
class="v4-field__error"
>{{ error }}</small>
</label>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
fetchReservationV4AccountLookups,
fetchReservationV4RateCodeLookups,
fetchReservationV4RoomTypeLookups,
} from '@/services/reservationService'
import type {
ReservationRecord,
ReservationV4CatalogLookupItem,
ReservationV4CatalogLookupResult,
ReservationV4TaskCardFieldResult,
} from '@/types/reservation'
import {
isReservationV4EditableField,
readV4FieldValue,
reservationV4LookupKindForOptionsSource,
reservationV4FieldKey,
stringifyReservationV4SafeDisplayValue,
type ReservationV4LookupKind,
} from '@/utils/reservationV4FieldRules'
const props = defineProps<{
fields: ReservationV4TaskCardFieldResult[]
modelValue: ReservationRecord
readOnly: boolean
validationErrors?: Record<string, string>
editableKeys?: string[]
hotelId?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: ReservationRecord]
}>()
const { t } = useI18n()
type FieldOption = {
value: string
label: string
item?: ReservationV4CatalogLookupItem
}
type LookupHintTone = 'normal' | 'warning' | 'error'
type LookupHint = {
key: string
message: string
tone: LookupHintTone
}
type LookupState = {
loading: boolean
error: string
result: ReservationV4CatalogLookupResult | null
}
type ExactLookupState = {
loading: boolean
searched: boolean
item: ReservationV4CatalogLookupItem | null
}
const invalidLookupValues = reactive<Record<string, string>>({})
const exactLookupStates = reactive<Record<string, ExactLookupState>>({})
const lookupStates = reactive<Record<ReservationV4LookupKind, LookupState>>({
ACCOUNT: createLookupState(),
ROOM_TYPE: createLookupState(),
RATE_CODE: createLookupState(),
})
const lookupRequestVersions: Record<ReservationV4LookupKind, number> = {
ACCOUNT: 0,
ROOM_TYPE: 0,
RATE_CODE: 0,
}
const lookupKinds = computed(() => {
const kinds = props.fields
.filter((field) => isSelectControl(field) && isEditable(field))
.map((field) => reservationV4LookupKindForOptionsSource(field.options_source))
.filter((kind): kind is ReservationV4LookupKind => Boolean(kind))
return [...new Set(kinds)]
})
const lookupKindKey = computed(() => lookupKinds.value.join('|'))
watch(
[lookupKindKey, () => props.hotelId],
() => {
resetLookupStates()
lookupKinds.value.forEach((kind) => {
void loadLookup(kind)
})
},
{ immediate: true },
)
watch(
[
() => props.modelValue,
() => lookupStates.ACCOUNT.result,
() => lookupStates.ROOM_TYPE.result,
() => lookupStates.RATE_CODE.result,
],
() => {
clearInvalidLoadedCatalogValues()
},
{ deep: true },
)
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
}
function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
const key = reservationV4FieldKey(field)
return !props.readOnly &&
(!props.editableKeys || props.editableKeys.includes(key)) &&
isReservationV4EditableField(field)
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return isSelectControl(field) &&
(Boolean(reservationV4LookupKindForOptionsSource(field.options_source)) || fixedFieldOptions(field).length > 0)
}
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'DATE'
}
function isCheckboxField(field: ReservationV4TaskCardFieldResult): boolean {
return ['CHECKBOX', 'BOOLEAN'].includes(normalizeControlType(field.control_type))
}
function isNumberField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'NUMBER'
}
function isTextAreaField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'TEXTAREA'
}
function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const options = kind
? (lookupStates[kind].result?.items ?? []).map((item) => ({
value: item.code,
label: lookupItemLabel(item),
item,
}))
: fixedFieldOptions(field)
const exactItem = exactLookupItemForField(field)
if (exactItem && !options.some((option) => option.value === exactItem.code)) {
options.push({
value: exactItem.code,
label: lookupItemLabel(exactItem),
item: exactItem,
})
}
return options
}
function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return false
}
const state = lookupStates[kind]
return state.loading || Boolean(state.error)
}
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
const value = readV4FieldValue(field, props.modelValue)
if (!isEditable(field)) {
return stringifyV4Value(value)
}
if (typeof value === 'number') {
return value
}
if (typeof value === 'string') {
return value
}
return stringifyV4Value(value)
}
function staticFieldValue(field: ReservationV4TaskCardFieldResult): string {
const value = fieldValue(field)
const matchedOption = fieldOptions(field).find((option) => option.value === value)
return matchedOption?.label ?? stringifyV4Value(value)
}
function checkboxValue(field: ReservationV4TaskCardFieldResult): boolean {
const value = readV4FieldValue(field, props.modelValue)
return value === true || value === 'true' || value === 1 || value === '1'
}
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
delete invalidLookupValues[reservationV4FieldKey(field)]
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.value,
})
}
function updateCheckboxField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement
delete invalidLookupValues[reservationV4FieldKey(field)]
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.checked,
})
}
function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return []
}
const state = lookupStates[kind]
if (state.loading) {
return [{
key: `${kind}-loading`,
message: t('taskV4.lookup.loading'),
tone: 'normal',
}]
}
if (state.error) {
return [{
key: `${kind}-error`,
message: state.error,
tone: 'error',
}]
}
const result = state.result
if (!result) {
return []
}
const hints: LookupHint[] = []
if (isExactLookupLoading(field)) {
hints.push({
key: `${kind}-exact-loading`,
message: t('taskV4.lookup.loading'),
tone: 'normal',
})
}
const selectedItem = selectedLookupItem(field)
if (kind === 'ACCOUNT' && selectedItem && (selectedItem.market_code || selectedItem.source_code)) {
hints.push({
key: `${kind}-derived`,
message: t('taskV4.lookup.accountDerived', {
market: selectedItem.market_code ?? '-',
source: selectedItem.source_code ?? '-',
}),
tone: 'normal',
})
}
if (result.stale) {
hints.push({
key: `${kind}-stale`,
message: t('taskV4.lookup.stale'),
tone: 'warning',
})
}
result.warnings.forEach((warning, index) => {
hints.push({
key: `${kind}-warning-${index}`,
message: t('taskV4.lookup.warning', { warning }),
tone: 'warning',
})
})
if (!result.items.length) {
hints.push({
key: `${kind}-empty`,
message: t('taskV4.lookup.empty'),
tone: 'warning',
})
}
if (result.catalog_source || result.catalog_version) {
hints.push({
key: `${kind}-meta`,
message: t('taskV4.lookup.catalogMeta', {
source: result.catalog_source ?? '-',
version: result.catalog_version ?? '-',
}),
tone: 'normal',
})
}
const invalidValue = invalidLookupValues[reservationV4FieldKey(field)]
if (invalidValue) {
hints.push({
key: `${kind}-invalid-current`,
message: t('taskV4.lookup.currentNotInCatalog', { value: invalidValue }),
tone: 'error',
})
}
return hints
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [
...(field.validation_errors ?? []),
fieldError(field),
].filter(Boolean)
}
function fieldError(field: ReservationV4TaskCardFieldResult): string {
return props.validationErrors?.[reservationV4FieldKey(field)] ?? ''
}
function stringifyV4Value(value: unknown): string {
return stringifyReservationV4SafeDisplayValue(
value,
t('taskV4.field.empty'),
t('taskV4.field.hidden'),
)
}
function normalizeControlType(value: string | null): string {
return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
}
function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
return ['SELECT', 'LOOKUP'].includes(normalizeControlType(field.control_type))
}
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const optionsSource = normalizeControlType(field.options_source)
if (optionsSource !== 'RESERVATION_V4_GROUP_BOOKING_STATUS_FIXED') {
return []
}
return [
{
value: 'TEN',
label: 'TEN-Tentative',
},
{
value: 'DEF',
label: 'DEF-Definite',
},
{
value: 'INQ',
label: 'INQ-Inquiry',
},
]
}
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
if (item.display_name && item.display_name !== item.code) {
return `${item.code} - ${item.display_name}`
}
return item.code
}
function selectedLookupItem(field: ReservationV4TaskCardFieldResult): ReservationV4CatalogLookupItem | null {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const value = String(readV4FieldValue(field, props.modelValue) || '')
if (!kind || !value) {
return null
}
return lookupStates[kind].result?.items.find((item) => item.code === value)
?? exactLookupStates[exactLookupKey(kind, value)]?.item
?? null
}
function createLookupState(): LookupState {
return {
loading: false,
error: '',
result: null,
}
}
function resetLookupStates(): void {
const allKinds: ReservationV4LookupKind[] = ['ACCOUNT', 'ROOM_TYPE', 'RATE_CODE']
allKinds.forEach((kind) => {
lookupRequestVersions[kind] += 1
lookupStates[kind].loading = false
lookupStates[kind].error = ''
lookupStates[kind].result = null
})
Object.keys(invalidLookupValues).forEach((key) => {
delete invalidLookupValues[key]
})
Object.keys(exactLookupStates).forEach((key) => {
delete exactLookupStates[key]
})
}
async function loadLookup(kind: ReservationV4LookupKind): Promise<void> {
const state = lookupStates[kind]
const requestVersion = ++lookupRequestVersions[kind]
state.loading = true
state.error = ''
try {
const result = await lookupFetcher(kind)({
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
page_num: 1,
page_size: 100,
})
if (requestVersion === lookupRequestVersions[kind]) {
state.result = {
...result,
items: result.items ?? [],
warnings: result.warnings ?? [],
}
}
} catch {
if (requestVersion === lookupRequestVersions[kind]) {
state.error = t('taskV4.lookup.error')
}
} finally {
if (requestVersion === lookupRequestVersions[kind]) {
state.loading = false
}
}
}
function lookupFetcher(kind: ReservationV4LookupKind) {
switch (kind) {
case 'ACCOUNT':
return fetchReservationV4AccountLookups
case 'ROOM_TYPE':
return fetchReservationV4RoomTypeLookups
case 'RATE_CODE':
return fetchReservationV4RateCodeLookups
}
}
function clearInvalidLoadedCatalogValues(): void {
const nextValues = { ...props.modelValue }
let changed = false
props.fields.forEach((field) => {
if (!isSelectField(field) || !isEditable(field)) {
return
}
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const result = kind ? lookupStates[kind].result : null
if (!kind || !result) {
return
}
const key = reservationV4FieldKey(field)
const currentValue = String(readV4FieldValue(field, nextValues) || '')
if (!currentValue) {
return
}
if (result.items.some((item) => item.code === currentValue)) {
delete invalidLookupValues[key]
return
}
if (!isFullyLoadedCatalog(result)) {
const exactState = exactLookupStates[exactLookupKey(kind, currentValue)]
if (!exactState) {
void loadExactLookup(kind, currentValue)
return
}
if (exactState.loading || (exactState.searched && exactState.item)) {
delete invalidLookupValues[key]
return
}
}
clearInvalidCatalogValue(field, currentValue, nextValues)
changed = true
})
if (changed) {
emit('update:modelValue', nextValues)
}
}
function isFullyLoadedCatalog(result: ReservationV4CatalogLookupResult): boolean {
return result.items.length >= result.page.total
}
async function loadExactLookup(kind: ReservationV4LookupKind, code: string): Promise<void> {
const key = exactLookupKey(kind, code)
exactLookupStates[key] = {
loading: true,
searched: false,
item: null,
}
try {
const result = await lookupFetcher(kind)({
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
keyword: code,
page_num: 1,
page_size: 20,
})
exactLookupStates[key] = {
loading: false,
searched: true,
item: (result.items ?? []).find((item) => item.code === code) ?? null,
}
} catch {
exactLookupStates[key] = {
loading: false,
searched: true,
item: null,
}
} finally {
clearInvalidLoadedCatalogValues()
}
}
function clearInvalidCatalogValue(
field: ReservationV4TaskCardFieldResult,
currentValue: string,
nextValues: ReservationRecord,
): void {
const key = reservationV4FieldKey(field)
invalidLookupValues[key] = currentValue
nextValues[key] = ''
}
function exactLookupItemForField(field: ReservationV4TaskCardFieldResult): ReservationV4CatalogLookupItem | null {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const value = String(readV4FieldValue(field, props.modelValue) || '')
if (!kind || !value) {
return null
}
return exactLookupStates[exactLookupKey(kind, value)]?.item ?? null
}
function isExactLookupLoading(field: ReservationV4TaskCardFieldResult): boolean {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const value = String(readV4FieldValue(field, props.modelValue) || '')
if (!kind || !value) {
return false
}
return exactLookupStates[exactLookupKey(kind, value)]?.loading ?? false
}
function exactLookupKey(kind: ReservationV4LookupKind, code: string): string {
return `${props.hotelId ?? ''}:${kind}:${code}`
}
</script>
<style scoped>
.v4-field-renderer {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.v4-field {
display: grid;
gap: 7px;
}
.v4-field__label {
display: flex;
align-items: center;
gap: 6px;
color: var(--th-color-slate-700);
font-size: 12px;
font-weight: 800;
}
.v4-field__label sup {
color: var(--th-color-danger);
}
.v4-field__label em {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-500);
font-size: 11px;
font-style: normal;
padding: 2px 6px;
}
.v4-field__control,
.v4-field__static {
min-height: 38px;
width: 100%;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: var(--th-color-slate-900);
font: inherit;
padding: 9px 10px;
}
.v4-field__control--textarea {
resize: vertical;
}
.v4-field__control--checkbox {
min-height: 18px;
width: 18px;
accent-color: var(--th-color-blue-600);
box-shadow: none;
padding: 0;
}
.v4-field__control[aria-invalid='true'] {
border-color: var(--th-color-danger);
box-shadow: 0 0 0 3px var(--th-color-danger-bg);
}
.v4-field__static {
display: block;
min-height: 38px;
background: var(--th-color-slate-50);
overflow-wrap: anywhere;
}
.v4-field__hint {
color: var(--th-color-slate-500);
font-size: 12px;
}
.v4-field__hint--warning {
color: var(--th-color-warning);
font-weight: 700;
}
.v4-field__hint--error {
color: var(--th-color-danger);
font-weight: 700;
}
.v4-field__error {
color: var(--th-color-danger);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 760px) {
.v4-field-renderer {
grid-template-columns: 1fr;
}
}
</style>