Files
th-hotel-simple/client/src/components/reservation/ReservationManualReviewResolutionPanel.vue
2026-07-12 09:23:03 +08:00

368 lines
9.5 KiB
Vue

<template>
<section class="manual-resolution">
<header>
<div>
<h2>{{ t('task.manualResolution.title') }}</h2>
<p>{{ t('task.manualResolution.description') }}</p>
</div>
<span class="manual-resolution__status">
{{ t('task.reviewStatus') }}: {{ reviewStatusLabel }}
</span>
</header>
<div
v-if="isResolved"
class="manual-resolution__resolved"
>
<strong>{{ t('task.manualResolution.resolvedTitle') }}</strong>
<pre>{{ formatJson(reviewResolution) }}</pre>
</div>
<template v-else>
<div
v-if="manualReview"
class="manual-resolution__meta"
>
<dl>
<div v-if="typeof manualReview.reason_code === 'string'">
<dt>{{ t('task.manualResolution.reasonCode') }}</dt>
<dd class="th-code">
{{ manualReview.reason_code }}
</dd>
</div>
<div v-if="typeof manualReview.review_instruction === 'string'">
<dt>{{ t('task.manualResolution.reviewInstruction') }}</dt>
<dd>{{ manualReview.review_instruction }}</dd>
</div>
</dl>
</div>
<div class="manual-resolution__form">
<label>
<span>{{ t('task.manualResolution.confirmedOrderId') }}</span>
<input
v-model="confirmedOrderId"
name="manual_resolution_confirmed_order_id"
type="text"
:disabled="busy"
:placeholder="taskOrderId"
>
</label>
<div
v-if="supportedMissingFields.length"
class="manual-resolution__fields"
>
<label
v-for="(item, index) in supportedMissingFields"
:key="item.key"
>
<span>
{{ item.label }}
<small class="th-code">{{ item.pointer ?? item.fieldPath }}</small>
</span>
<input
v-model="fieldOverrideInputs[item.key]"
:name="`manual_resolution_field_override_${index}`"
type="text"
:disabled="busy"
:placeholder="t('task.manualResolution.fieldValuePlaceholder')"
>
</label>
</div>
<p
v-else
class="manual-resolution__empty"
>
{{ t('task.manualResolution.noSupportedMissingFields') }}
</p>
<label>
<span>{{ t('task.manualResolution.reason') }}</span>
<textarea
v-model="reason"
name="manual_resolution_reason"
rows="3"
:disabled="busy"
:placeholder="t('task.manualResolution.reasonPlaceholder')"
/>
</label>
</div>
<div
v-if="formErrorMessage"
class="manual-resolution__error"
>
{{ formErrorMessage }}
</div>
<footer>
<button
type="button"
class="primary-button"
:disabled="busy || !canSubmit"
@click="submitResolution"
>
<i
class="pi pi-check-circle"
aria-hidden="true"
/>
{{ t('task.manualResolution.submit') }}
</button>
</footer>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
ReservationManualReviewResolutionRequest,
ReservationRecord,
ReservationTaskFieldResult,
} from '@/types/reservation'
import { normalizeStableCode } from '@/utils/reservationDisplay'
import { readReservationFieldValue } from '@/utils/reservationFieldRules'
interface ManualResolutionFieldItem {
key: string
pointer?: string
fieldPath: string
label: string
field?: ReservationTaskFieldResult
}
const props = withDefaults(
defineProps<{
taskOrderId: string
reviewStatus?: string | null
reviewResolution?: ReservationRecord | null
manualReview?: ReservationRecord | null
fields?: ReservationTaskFieldResult[]
fieldValues?: ReservationRecord
busy?: boolean
}>(),
{
reviewStatus: null,
reviewResolution: null,
manualReview: null,
fields: () => [],
fieldValues: () => ({}),
busy: false,
},
)
const emit = defineEmits<{
submit: [request: ReservationManualReviewResolutionRequest]
}>()
const { t } = useI18n()
const confirmedOrderId = ref(props.taskOrderId)
const reason = ref('')
const formErrorMessage = ref('')
const fieldOverrideInputs = ref<Record<string, string>>({})
const isResolved = computed(() => normalizeStableCode(props.reviewStatus) === 'RESOLVED')
const reviewStatusLabel = computed(() => props.reviewStatus || t('task.manualResolution.pendingStatus'))
const supportedMissingFields = computed(() =>
extractMissingFields(props.manualReview)
.map((reference) => resolveMissingField(reference))
.filter((item): item is ManualResolutionFieldItem => item !== null),
)
const canSubmit = computed(() => {
if (!supportedMissingFields.value.length || !confirmedOrderId.value.trim()) {
return false
}
return supportedMissingFields.value.every((item) => String(fieldOverrideInputs.value[item.key] ?? '').trim())
})
watch(
() => props.taskOrderId,
(nextOrderId) => {
confirmedOrderId.value = nextOrderId
},
)
watch(
supportedMissingFields,
(items) => {
const nextInputs: Record<string, string> = {}
items.forEach((item) => {
nextInputs[item.key] = fieldOverrideInputs.value[item.key] || readCurrentFieldValue(item)
})
fieldOverrideInputs.value = nextInputs
},
{ immediate: true },
)
function submitResolution(): void {
formErrorMessage.value = ''
if (!canSubmit.value) {
formErrorMessage.value = t('task.manualResolution.fieldRequired')
return
}
emit('submit', {
confirmed_order_id: confirmedOrderId.value.trim(),
reason: reason.value.trim() || undefined,
field_overrides: supportedMissingFields.value.map((item) => ({
...(item.pointer ? { field_pointer: item.pointer } : {}),
field_path: item.fieldPath,
value: String(fieldOverrideInputs.value[item.key] ?? '').trim(),
})),
})
}
function extractMissingFields(manualReview: ReservationRecord | null | undefined): string[] {
const missingFields = manualReview?.missing_fields
if (!Array.isArray(missingFields)) {
return []
}
return missingFields.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
}
function resolveMissingField(reference: string): ManualResolutionFieldItem | null {
const pointer = reference.startsWith('/') ? reference : undefined
const referenceFieldPath = pointer ? fieldPointerToFieldPath(pointer) : reference
const field = props.fields.find((item) =>
item.field_pointer === reference ||
item.field_path === referenceFieldPath ||
item.legacy_field_path === referenceFieldPath,
)
if (field) {
return {
key: field.field_pointer ?? field.field_path,
pointer: field.field_pointer ?? pointer,
fieldPath: field.field_path,
label: field.display_name,
field,
}
}
return null
}
function readCurrentFieldValue(item: ManualResolutionFieldItem): string {
const value = item.field
? readReservationFieldValue(item.field, props.fieldValues)
: props.fieldValues[item.fieldPath]
return value === undefined || value === null ? '' : String(value)
}
function fieldPointerToFieldPath(pointer: string): string {
return pointer.replace(/^\//, '').replace(/\//g, '.')
}
function formatJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2)
}
</script>
<style scoped>
.manual-resolution {
display: grid;
gap: 14px;
border: 1px solid var(--th-color-warning);
border-radius: var(--th-radius-lg);
background: color-mix(in srgb, var(--th-color-warning) 8%, var(--th-color-white));
padding: 16px;
}
.manual-resolution header {
display: flex;
justify-content: space-between;
gap: 14px;
}
.manual-resolution h2,
.manual-resolution p {
margin: 0;
}
.manual-resolution p {
margin-top: 4px;
color: var(--th-color-slate-500);
font-size: 13px;
}
.manual-resolution__status {
align-self: flex-start;
border-radius: 999px;
background: var(--th-color-white);
color: var(--th-color-warning);
font-size: 12px;
font-weight: 800;
padding: 5px 10px;
white-space: nowrap;
}
.manual-resolution__meta dl,
.manual-resolution__form,
.manual-resolution__fields {
display: grid;
gap: 12px;
}
.manual-resolution__meta dl {
margin: 0;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.manual-resolution__meta dt {
color: var(--th-color-slate-500);
font-size: 12px;
}
.manual-resolution__meta dd {
margin: 3px 0 0;
font-weight: 800;
}
.manual-resolution label {
display: grid;
gap: 6px;
color: var(--th-color-slate-600);
font-size: 12px;
font-weight: 800;
}
.manual-resolution label small {
display: block;
margin-top: 3px;
color: var(--th-color-slate-400);
font-weight: 700;
}
.manual-resolution input,
.manual-resolution textarea {
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-md);
background: var(--th-color-white);
color: var(--th-color-slate-900);
font: inherit;
padding: 10px 12px;
}
.manual-resolution__error,
.manual-resolution__empty {
color: var(--th-color-danger);
font-size: 13px;
font-weight: 800;
}
.manual-resolution footer {
display: flex;
justify-content: flex-end;
}
.manual-resolution__resolved pre {
margin: 8px 0 0;
max-height: 180px;
overflow: auto;
border-radius: var(--th-radius-md);
background: var(--th-color-slate-900);
color: var(--th-color-white);
padding: 12px;
}
</style>