368 lines
9.5 KiB
Vue
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>
|