1559 lines
44 KiB
Vue
1559 lines
44 KiB
Vue
<template>
|
|
<section class="th-section task-detail-panel">
|
|
<div
|
|
v-if="loading"
|
|
class="task-state"
|
|
>
|
|
{{ t('common.loading') }}
|
|
</div>
|
|
<div
|
|
v-else-if="errorMessage"
|
|
class="task-state task-state--error"
|
|
>
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<template v-else-if="detail">
|
|
<header class="task-detail-header">
|
|
<div>
|
|
<p class="task-eyebrow">
|
|
{{ formatReservationTaskRouteSummary(t, detail) }}
|
|
</p>
|
|
<h1>{{ t('task.title') }} {{ detail.task_id }}</h1>
|
|
<span class="task-contract">
|
|
{{ t('task.fieldContract') }}: {{ detail.field_contract_version }}
|
|
</span>
|
|
<div class="task-links">
|
|
<RouterLink
|
|
v-if="!isReadonlyDiagnosticDetail"
|
|
class="task-link"
|
|
:to="`/reservation/orders/${detail.order_id}`"
|
|
>
|
|
{{ t('task.viewOrder') }} {{ detail.order_id }}
|
|
</RouterLink>
|
|
<span
|
|
v-if="!detail.source_message_id"
|
|
class="task-link task-link--disabled"
|
|
>
|
|
{{ t('task.viewConversation') }} · {{ t('common.endpointMissing') }}
|
|
</span>
|
|
<RouterLink
|
|
v-else
|
|
class="task-link"
|
|
:to="`/reservation/source-messages/${detail.source_message_id}/conversation`"
|
|
>
|
|
{{ t('task.viewConversation') }}
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
<ReservationStatusBadge :status="detail.task_status" />
|
|
</header>
|
|
|
|
<section class="task-source-email">
|
|
<header>
|
|
<div>
|
|
<p>{{ t('task.sourceEmailPreview') }}</p>
|
|
<h2>{{ detail.source_subject ?? t('task.sourceSummaryPending') }}</h2>
|
|
</div>
|
|
<span
|
|
v-if="!detail.source_message_id"
|
|
class="task-link task-link--disabled"
|
|
>
|
|
{{ t('task.sourceEmailFullConversation') }} · {{ t('common.endpointMissing') }}
|
|
</span>
|
|
<RouterLink
|
|
v-else
|
|
class="task-link"
|
|
:to="`/reservation/source-messages/${detail.source_message_id}/conversation`"
|
|
>
|
|
{{ t('task.sourceEmailFullConversation') }}
|
|
</RouterLink>
|
|
</header>
|
|
<dl class="task-source-email__meta">
|
|
<div>
|
|
<dt>{{ t('task.sourceSender') }}</dt>
|
|
<dd>{{ detail.source_sender_summary ?? '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.sourceReceivedAt') }}</dt>
|
|
<dd>{{ formatReservationDateTime(detail.source_received_at) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.sourceMessageId') }}</dt>
|
|
<dd class="th-code">
|
|
{{ detail.source_message_id || '-' }}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('conversation.messageCount') }}</dt>
|
|
<dd>{{ detail.conversation_message_count ?? '-' }}</dd>
|
|
</div>
|
|
</dl>
|
|
<div class="task-source-email__content">
|
|
<section>
|
|
<h3>{{ t('task.sourceEmailExcerpt') }}</h3>
|
|
<p>{{ sourceEmailExcerpt ?? t('task.sourceEmailNoExcerpt') }}</p>
|
|
</section>
|
|
<section v-if="sourceEmailAttachments.length">
|
|
<h3>{{ t('task.sourceEmailAttachments') }}</h3>
|
|
<ul>
|
|
<li
|
|
v-for="item in sourceEmailAttachments"
|
|
:key="item.key"
|
|
>
|
|
<i
|
|
class="pi pi-paperclip"
|
|
aria-hidden="true"
|
|
/>
|
|
<span>
|
|
{{ item.name }}
|
|
<small v-if="item.meta">{{ item.meta }}</small>
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-if="isSourceMessageOnlyDetail"
|
|
class="source-only-result"
|
|
>
|
|
<header>
|
|
<h2>{{ t('task.sourceOnlyResult') }}</h2>
|
|
<p>{{ t('task.sourceOnlyReadonly') }}</p>
|
|
</header>
|
|
<dl v-if="sourceMessageOnlyResult">
|
|
<div>
|
|
<dt>{{ t('task.entryResultCode') }}</dt>
|
|
<dd class="th-code">
|
|
{{ sourceMessageOnlyResult.entry_result_code || '-' }}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.entryResultMeaning') }}</dt>
|
|
<dd>{{ sourceMessageOnlyResult.entry_result_meaning || '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.entryResultDescription') }}</dt>
|
|
<dd>{{ sourceMessageOnlyResult.entry_result_description || '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.entryResultSourceMessageId') }}</dt>
|
|
<dd class="th-code">
|
|
{{ sourceMessageOnlyResult.entry_result_source_message_id || '-' }}
|
|
</dd>
|
|
</div>
|
|
<div class="source-only-result__raw">
|
|
<dt>{{ t('task.rawAnswer') }}</dt>
|
|
<dd>
|
|
<pre>{{ sourceMessageOnlyResult.raw_answer || '-' }}</pre>
|
|
</dd>
|
|
</div>
|
|
<div class="source-only-result__raw">
|
|
<dt>{{ t('task.agentAssessment') }}</dt>
|
|
<dd>
|
|
<pre>{{ formatJson(sourceMessageOnlyResult.agent_assessment) }}</pre>
|
|
</dd>
|
|
</div>
|
|
<div class="source-only-result__raw">
|
|
<dt>{{ t('task.sourceNotification') }}</dt>
|
|
<dd>
|
|
<pre>{{ formatJson(sourceMessageOnlyResult.notification) }}</pre>
|
|
</dd>
|
|
</div>
|
|
<div class="source-only-result__raw">
|
|
<dt>{{ t('task.manualReviewData') }}</dt>
|
|
<dd>
|
|
<pre>{{ formatJson(sourceMessageOnlyResult.manual_review) }}</pre>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<p
|
|
v-else
|
|
class="source-only-result__empty"
|
|
>
|
|
{{ t('task.noSourceOnlyResult') }}
|
|
</p>
|
|
</section>
|
|
|
|
<section
|
|
v-if="manualReviewRecord"
|
|
class="task-ai-diagnostics"
|
|
>
|
|
<h2>{{ t('task.manualReviewData') }}</h2>
|
|
<pre>{{ formatJson(manualReviewRecord) }}</pre>
|
|
</section>
|
|
|
|
<section
|
|
v-if="manualReviewEvidence"
|
|
class="manual-review-evidence"
|
|
>
|
|
<header>
|
|
<h2>{{ t('task.manualReviewEvidence') }}</h2>
|
|
<p>{{ manualReviewEvidence.visibleReason || t('task.manualReviewEvidenceFallback') }}</p>
|
|
</header>
|
|
<dl>
|
|
<div>
|
|
<dt>{{ t('task.manualResolution.reasonCode') }}</dt>
|
|
<dd class="th-code">
|
|
{{ manualReviewEvidence.reasonCode || '-' }}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.manualReviewMissingFields') }}</dt>
|
|
<dd>{{ formatStringList(manualReviewEvidence.missingFields) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.manualReviewBlockingPoints') }}</dt>
|
|
<dd>{{ formatStringList(manualReviewEvidence.blockingPoints) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.manualReviewConflictingPoints') }}</dt>
|
|
<dd>{{ formatStringList(manualReviewEvidence.conflictingPoints) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.manualReviewSuggestedActions') }}</dt>
|
|
<dd>{{ formatStringList(manualReviewEvidence.suggestedHumanActions) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.manualReviewEvidenceToCheck') }}</dt>
|
|
<dd>{{ formatStringList(manualReviewEvidence.evidenceToCheck) }}</dd>
|
|
</div>
|
|
</dl>
|
|
<div class="manual-review-evidence__candidates">
|
|
<h3>{{ t('task.parentIdentityCandidates') }}</h3>
|
|
<div
|
|
v-if="manualReviewEvidence.parentIdentityCandidates.length"
|
|
class="candidate-grid"
|
|
>
|
|
<article
|
|
v-for="(candidate, index) in manualReviewEvidence.parentIdentityCandidates"
|
|
:key="`${candidate.title}-${candidate.summary}-${index}`"
|
|
class="candidate-item"
|
|
>
|
|
<strong>{{ candidate.title || t('task.parentIdentityCandidate') }}</strong>
|
|
<span class="th-code">{{ candidate.summary || '-' }}</span>
|
|
<small>{{ candidate.sourceSummary || '-' }}</small>
|
|
<dl
|
|
v-if="candidate.attributes.length"
|
|
class="candidate-item__attributes"
|
|
>
|
|
<div
|
|
v-for="attribute in candidate.attributes"
|
|
:key="attribute.key"
|
|
>
|
|
<dt>{{ attribute.key }}</dt>
|
|
<dd>{{ attribute.value }}</dd>
|
|
</div>
|
|
</dl>
|
|
</article>
|
|
</div>
|
|
<p
|
|
v-else
|
|
class="manual-review-evidence__empty"
|
|
>
|
|
{{ t('task.noParentIdentityCandidates') }}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-if="adapterContractErrors.length"
|
|
class="task-ai-diagnostics task-ai-diagnostics--error"
|
|
>
|
|
<h2>{{ t('task.adapterContractErrors') }}</h2>
|
|
<article
|
|
v-for="item in adapterContractErrors"
|
|
:key="item.transition_id || `${item.route_code}-${item.array_index}`"
|
|
>
|
|
<strong>{{ item.adapter_error_code || item.route_code || t('resultType.ADAPTER_CONTRACT_ERROR') }}</strong>
|
|
<p>{{ item.adapter_error_message || '-' }}</p>
|
|
<pre>{{ formatJson(item.payload_fragment) }}</pre>
|
|
</article>
|
|
</section>
|
|
|
|
<section
|
|
v-if="unhandledIntents.length"
|
|
class="task-ai-diagnostics"
|
|
>
|
|
<h2>{{ t('task.unhandledIntents') }}</h2>
|
|
<article
|
|
v-for="item in unhandledIntents"
|
|
:key="item.transition_id || `${item.route_code}-${item.array_index}`"
|
|
>
|
|
<strong>{{ item.route_code || item.task_subtype || t('resultType.UNHANDLED_CURRENT_INTENT') }}</strong>
|
|
<pre>{{ formatJson(item.payload_fragment) }}</pre>
|
|
</article>
|
|
</section>
|
|
|
|
<div
|
|
v-if="detail.availability.blocked"
|
|
class="task-alert"
|
|
>
|
|
<i
|
|
class="pi pi-lock"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ formatReservationMaybeStableReason(t, detail.availability.blocked_reason, 'task.blocked') }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="notice"
|
|
class="task-notice"
|
|
>
|
|
{{ notice }}
|
|
</div>
|
|
<div
|
|
v-if="actionErrorMessage"
|
|
class="task-alert"
|
|
>
|
|
<i
|
|
class="pi pi-exclamation-triangle"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ actionErrorMessage }}
|
|
</div>
|
|
|
|
<div class="task-detail-body">
|
|
<div class="task-card-main">
|
|
<ReservationManualReviewResolutionPanel
|
|
v-if="isTypeKnownManualReviewDetail"
|
|
:task-order-id="detail.order_id"
|
|
:review-status="detail.review_status"
|
|
:review-resolution="detail.review_resolution"
|
|
:manual-review="detail.manual_review"
|
|
:fields="detail.fields"
|
|
:field-values="fieldValues"
|
|
:busy="mutationBusy !== null"
|
|
@submit="submitManualReviewResolution"
|
|
/>
|
|
|
|
<ReservationManualReviewConversionPanel
|
|
v-if="isFallbackManualReviewDetail && !isTypeKnownManualReviewDetail && !isReadonlyDiagnosticDetail"
|
|
:busy="mutationBusy !== null"
|
|
@submit="submitManualReviewConversion"
|
|
/>
|
|
|
|
<ReservationTaskFieldRenderer
|
|
v-model="fieldValues"
|
|
:fields="detail.fields"
|
|
:read-only="isFieldRendererReadOnly"
|
|
:validation-errors="validationErrorMessages"
|
|
/>
|
|
|
|
<footer
|
|
v-if="canSave || canConfirm"
|
|
class="task-actions"
|
|
>
|
|
<button
|
|
v-if="canEditTask"
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="!canSave || mutationBusy !== null"
|
|
@click="saveDraft"
|
|
>
|
|
<i
|
|
class="pi pi-save"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ t('task.saveDraft') }}
|
|
</button>
|
|
<button
|
|
v-if="canConfirmTask"
|
|
type="button"
|
|
class="primary-button"
|
|
:disabled="!canConfirm || mutationBusy !== null"
|
|
@click="confirmTask"
|
|
>
|
|
<i
|
|
class="pi pi-check"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ t('task.confirm') }}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
|
|
<aside class="task-side-stack">
|
|
<ReservationOperaPanel
|
|
v-if="!isReadonlyDiagnosticDetail && !isPendingTypeKnownManualReviewDetail"
|
|
:task-id="detail.task_id"
|
|
:operations="detail.opera_operations"
|
|
:executable="canExecuteOpera"
|
|
:show-actions="canExecuteOpera"
|
|
@operation-updated="replaceOperation"
|
|
/>
|
|
<ReservationAuditTimeline
|
|
v-if="canViewAudit"
|
|
:items="audits"
|
|
/>
|
|
</aside>
|
|
</div>
|
|
|
|
<details class="task-route-info">
|
|
<summary>{{ t('task.routeInfo') }}</summary>
|
|
<dl>
|
|
<div>
|
|
<dt>{{ t('task.systemTaskType') }}</dt>
|
|
<dd>
|
|
{{ formatReservationTaskCard(t, detail.system_task_type) }}
|
|
<small class="th-code">{{ detail.system_task_type }}</small>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.taskCardType') }}</dt>
|
|
<dd>
|
|
{{ formatReservationTaskCardLabel(t, detail) }}
|
|
<small class="th-code">{{ detail.task_card_type }}</small>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.resultType') }}</dt>
|
|
<dd>{{ detail.result_type ? formatReservationResultType(t, detail.result_type) : '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.aiTaskType') }}</dt>
|
|
<dd>{{ detail.ai_task_type || '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.taskSubtype') }}</dt>
|
|
<dd>{{ detail.task_subtype ? formatReservationRouteCode(t, detail.task_subtype) : '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.routeCode') }}</dt>
|
|
<dd class="th-code">
|
|
{{ detail.route_code || '-' }}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.systemProcessCategory') }}</dt>
|
|
<dd>{{ detail.system_process_category ? formatReservationSystemProcessCategory(t, detail.system_process_category) : '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('task.reviewStatus') }}</dt>
|
|
<dd>{{ detail.review_status || '-' }}</dd>
|
|
</div>
|
|
</dl>
|
|
</details>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { RouterLink } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import ReservationAuditTimeline from '@/components/reservation/ReservationAuditTimeline.vue'
|
|
import ReservationManualReviewConversionPanel from '@/components/reservation/ReservationManualReviewConversionPanel.vue'
|
|
import ReservationManualReviewResolutionPanel from '@/components/reservation/ReservationManualReviewResolutionPanel.vue'
|
|
import ReservationOperaPanel from '@/components/reservation/ReservationOperaPanel.vue'
|
|
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
|
import ReservationTaskFieldRenderer from '@/components/reservation/ReservationTaskFieldRenderer.vue'
|
|
import {
|
|
convertReservationManualReviewTask,
|
|
confirmReservationTask,
|
|
fetchReservationTaskAudits,
|
|
fetchReservationTaskDetail,
|
|
resolveReservationManualReviewTask,
|
|
saveReservationTaskDraft,
|
|
} from '@/services/reservationService'
|
|
import type {
|
|
ReservationManualReviewConversionRequest,
|
|
ReservationManualReviewResolutionRequest,
|
|
ReservationOperaOperationResult,
|
|
ReservationRecord,
|
|
ReservationTaskAuditLogResult,
|
|
ReservationTaskDetailResult,
|
|
} from '@/types/reservation'
|
|
import {
|
|
buildEditableFieldValues,
|
|
buildInitialFieldValues,
|
|
readReservationFieldValue,
|
|
validateReservationFieldValues,
|
|
type ReservationFieldValidationErrors,
|
|
} from '@/utils/reservationFieldRules'
|
|
import {
|
|
formatReservationResultType,
|
|
formatReservationRouteCode,
|
|
formatReservationMaybeStableReason,
|
|
formatReservationSystemProcessCategory,
|
|
formatReservationTaskCard,
|
|
formatReservationTaskCardLabel,
|
|
formatReservationTaskRouteSummary,
|
|
isReservationFallbackManualReviewTask,
|
|
isReservationReadonlyDiagnosticTask,
|
|
isReservationSourceMessageNotificationTask,
|
|
isReservationTypeKnownManualReviewTask,
|
|
normalizeStableCode,
|
|
} from '@/utils/reservationDisplay'
|
|
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
|
|
const props = defineProps<{
|
|
taskId: string
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const authStore = useAuthStore()
|
|
const loading = ref(false)
|
|
const errorMessage = ref('')
|
|
const actionErrorMessage = ref('')
|
|
const notice = ref('')
|
|
const detail = ref<ReservationTaskDetailResult | null>(null)
|
|
const audits = ref<ReservationTaskAuditLogResult[]>([])
|
|
const fieldValues = ref<ReservationRecord>({})
|
|
const validationErrors = ref<ReservationFieldValidationErrors>({})
|
|
const validationSummaryActive = ref(false)
|
|
const mutationBusy = ref<'save' | 'confirm' | 'manual-convert' | 'manual-resolve' | null>(null)
|
|
|
|
const isSourceMessageOnlyDetail = computed(() =>
|
|
isReservationSourceMessageNotificationTask(detail.value),
|
|
)
|
|
const isReadonlyDiagnosticDetail = computed(() => isReservationReadonlyDiagnosticTask(detail.value))
|
|
const isTypeKnownManualReviewDetail = computed(() => isReservationTypeKnownManualReviewTask(detail.value))
|
|
const isPendingTypeKnownManualReviewDetail = computed(
|
|
() => isTypeKnownManualReviewDetail.value && normalizeStableCode(detail.value?.review_status) !== 'RESOLVED',
|
|
)
|
|
const isFallbackManualReviewDetail = computed(() => isReservationFallbackManualReviewTask(detail.value))
|
|
const sourceMessageOnlyResult = computed(() => detail.value?.source_message_only_result ?? null)
|
|
const manualReviewRecord = computed(() => detail.value?.manual_review ?? sourceMessageOnlyResult.value?.manual_review ?? null)
|
|
const manualReviewEvidence = computed(() => buildManualReviewEvidence(detail.value, manualReviewRecord.value))
|
|
const adapterContractErrors = computed(() => detail.value?.adapter_contract_errors ?? [])
|
|
const unhandledIntents = computed(() => detail.value?.unhandled_intents ?? [])
|
|
const sourceEmailExcerpt = computed(() => readSourceEmailExcerpt(detail.value, fieldValues.value))
|
|
const sourceEmailAttachments = computed(() => readSourceEmailAttachments(detail.value, fieldValues.value))
|
|
const isFieldRendererReadOnly = computed(
|
|
() =>
|
|
Boolean(detail.value?.availability.read_only) ||
|
|
Boolean(detail.value?.availability.blocked) ||
|
|
isReadonlyDiagnosticDetail.value ||
|
|
isPendingTypeKnownManualReviewDetail.value,
|
|
)
|
|
const canEditTask = computed(
|
|
() =>
|
|
authStore.hasPermission('RESERVATION_TASK_EDIT') &&
|
|
!isReadonlyDiagnosticDetail.value &&
|
|
!isPendingTypeKnownManualReviewDetail.value &&
|
|
!detail.value?.availability.blocked &&
|
|
!detail.value?.availability.read_only,
|
|
)
|
|
const canConfirmTask = computed(
|
|
() =>
|
|
authStore.hasPermission('RESERVATION_TASK_CONFIRM') &&
|
|
!isReadonlyDiagnosticDetail.value &&
|
|
!isPendingTypeKnownManualReviewDetail.value &&
|
|
!detail.value?.availability.blocked &&
|
|
!detail.value?.availability.read_only,
|
|
)
|
|
const canExecuteOpera = computed(
|
|
() =>
|
|
authStore.hasPermission('RESERVATION_OPERA_SIM_EXECUTE') &&
|
|
!isReadonlyDiagnosticDetail.value &&
|
|
!isPendingTypeKnownManualReviewDetail.value &&
|
|
!detail.value?.availability.blocked &&
|
|
!detail.value?.availability.read_only &&
|
|
Boolean(detail.value?.availability.executable),
|
|
)
|
|
const canViewAudit = computed(() => authStore.hasPermission('RESERVATION_AUDIT_READ'))
|
|
const canSave = computed(() => canEditTask.value && Boolean(detail.value?.availability.editable))
|
|
const canConfirm = computed(() => canConfirmTask.value && Boolean(detail.value?.availability.confirmable))
|
|
const validationErrorMessages = computed(() => toValidationErrorMessages(validationErrors.value))
|
|
|
|
watch(
|
|
() => props.taskId,
|
|
(taskId) => {
|
|
void loadTask(taskId)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
fieldValues,
|
|
() => {
|
|
refreshValidationAfterFieldChange()
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
async function loadTask(taskId: string): Promise<void> {
|
|
loading.value = true
|
|
errorMessage.value = ''
|
|
actionErrorMessage.value = ''
|
|
notice.value = ''
|
|
try {
|
|
const [nextDetail, nextAudits] = await Promise.all([
|
|
fetchReservationTaskDetail(taskId),
|
|
canViewAudit.value
|
|
? fetchReservationTaskAudits(taskId)
|
|
: Promise.resolve({
|
|
task_id: taskId,
|
|
items: [],
|
|
}),
|
|
])
|
|
applyTaskData(nextDetail, nextAudits.items)
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof Error ? error.message : String(error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function saveDraft(): Promise<void> {
|
|
await mutateTaskPayload('save')
|
|
}
|
|
|
|
async function confirmTask(): Promise<void> {
|
|
await mutateTaskPayload('confirm')
|
|
}
|
|
|
|
async function submitManualReviewConversion(request: ReservationManualReviewConversionRequest): Promise<void> {
|
|
if (!detail.value) {
|
|
return
|
|
}
|
|
actionErrorMessage.value = ''
|
|
notice.value = ''
|
|
if (request.target_task_type !== 'NEW_BOOKING' && !request.target_order_id) {
|
|
actionErrorMessage.value = t('task.manualConversion.targetOrderRequired')
|
|
return
|
|
}
|
|
|
|
mutationBusy.value = 'manual-convert'
|
|
try {
|
|
const result = await convertReservationManualReviewTask(detail.value.task_id, request)
|
|
const [nextDetail, nextAudits] = await Promise.all([
|
|
fetchReservationTaskDetail(detail.value.task_id),
|
|
canViewAudit.value
|
|
? fetchReservationTaskAudits(detail.value.task_id)
|
|
: Promise.resolve({
|
|
task_id: detail.value.task_id,
|
|
items: [],
|
|
}),
|
|
])
|
|
applyTaskData(nextDetail, nextAudits.items)
|
|
notice.value = t('task.manualConversion.succeeded', {
|
|
taskType: formatReservationTaskCard(t, result.task_card_type),
|
|
orderId: result.order_id,
|
|
})
|
|
} catch (error) {
|
|
actionErrorMessage.value = error instanceof Error ? error.message : String(error)
|
|
} finally {
|
|
mutationBusy.value = null
|
|
}
|
|
}
|
|
|
|
async function submitManualReviewResolution(request: ReservationManualReviewResolutionRequest): Promise<void> {
|
|
if (!detail.value) {
|
|
return
|
|
}
|
|
actionErrorMessage.value = ''
|
|
notice.value = ''
|
|
mutationBusy.value = 'manual-resolve'
|
|
try {
|
|
await resolveReservationManualReviewTask(detail.value.task_id, request)
|
|
const [nextDetail, nextAudits] = await Promise.all([
|
|
fetchReservationTaskDetail(detail.value.task_id),
|
|
canViewAudit.value
|
|
? fetchReservationTaskAudits(detail.value.task_id)
|
|
: Promise.resolve({
|
|
task_id: detail.value.task_id,
|
|
items: [],
|
|
}),
|
|
])
|
|
applyTaskData(nextDetail, nextAudits.items)
|
|
notice.value = t('task.manualResolution.succeeded')
|
|
} catch (error) {
|
|
actionErrorMessage.value = error instanceof Error ? error.message : String(error)
|
|
} finally {
|
|
mutationBusy.value = null
|
|
}
|
|
}
|
|
|
|
async function mutateTaskPayload(action: 'save' | 'confirm'): Promise<void> {
|
|
if (!detail.value) {
|
|
return
|
|
}
|
|
if ((action === 'save' && !canSave.value) || (action === 'confirm' && !canConfirm.value)) {
|
|
return
|
|
}
|
|
const taskId = detail.value.task_id
|
|
mutationBusy.value = action
|
|
actionErrorMessage.value = ''
|
|
validationSummaryActive.value = false
|
|
notice.value = ''
|
|
try {
|
|
if (action === 'confirm' && !validateBeforeConfirm(detail.value)) {
|
|
return
|
|
}
|
|
const request = {
|
|
field_values: buildEditableFieldValues(detail.value.fields, fieldValues.value, detail.value.availability.read_only),
|
|
}
|
|
const result =
|
|
action === 'save' ? await saveReservationTaskDraft(taskId, request) : await confirmReservationTask(taskId, request)
|
|
detail.value = {
|
|
...detail.value,
|
|
task_status: result.task_status,
|
|
draft_payload: result.draft_payload,
|
|
confirmed_payload: result.confirmed_payload,
|
|
opera_operations: result.opera_operations,
|
|
}
|
|
if (canViewAudit.value) {
|
|
await refreshAudits(taskId)
|
|
}
|
|
notice.value = action === 'save' ? t('task.savedLocally') : t('task.confirmedLocally')
|
|
} catch (error) {
|
|
actionErrorMessage.value = error instanceof Error ? error.message : String(error)
|
|
} finally {
|
|
mutationBusy.value = null
|
|
}
|
|
}
|
|
|
|
function applyTaskData(nextDetail: ReservationTaskDetailResult, nextAudits: ReservationTaskAuditLogResult[]): void {
|
|
detail.value = nextDetail
|
|
audits.value = nextAudits
|
|
fieldValues.value = buildInitialFieldValues(
|
|
nextDetail.fields,
|
|
nextDetail.draft_payload ?? nextDetail.confirmed_payload,
|
|
nextDetail.field_contract_version,
|
|
)
|
|
validationErrors.value = {}
|
|
validationSummaryActive.value = false
|
|
}
|
|
|
|
function validateBeforeConfirm(taskDetail: ReservationTaskDetailResult): boolean {
|
|
const nextErrors = validateReservationFieldValues(taskDetail.fields, fieldValues.value)
|
|
validationErrors.value = nextErrors
|
|
if (hasValidationErrors(nextErrors)) {
|
|
actionErrorMessage.value = t('task.validationFailed')
|
|
validationSummaryActive.value = true
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function refreshValidationAfterFieldChange(): void {
|
|
if (!detail.value || (!hasValidationErrors(validationErrors.value) && !validationSummaryActive.value)) {
|
|
return
|
|
}
|
|
const nextErrors = validateReservationFieldValues(detail.value.fields, fieldValues.value)
|
|
validationErrors.value = nextErrors
|
|
if (!hasValidationErrors(nextErrors) && validationSummaryActive.value) {
|
|
actionErrorMessage.value = ''
|
|
validationSummaryActive.value = false
|
|
}
|
|
}
|
|
|
|
function hasValidationErrors(errors: ReservationFieldValidationErrors): boolean {
|
|
return Object.keys(errors).length > 0
|
|
}
|
|
|
|
function toValidationErrorMessages(errors: ReservationFieldValidationErrors): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(errors).map(([fieldPath, error]) => [
|
|
fieldPath,
|
|
t(`task.validation.${error.code}`, { field: error.field_name }),
|
|
]),
|
|
)
|
|
}
|
|
|
|
async function refreshAudits(taskId: string): Promise<void> {
|
|
const nextAudits = await fetchReservationTaskAudits(taskId)
|
|
audits.value = nextAudits.items
|
|
}
|
|
|
|
function replaceOperation(operation: ReservationOperaOperationResult): void {
|
|
if (!detail.value) {
|
|
return
|
|
}
|
|
detail.value = {
|
|
...detail.value,
|
|
opera_operations: detail.value.opera_operations.map((item) =>
|
|
item.operation_id === operation.operation_id ? operation : item,
|
|
),
|
|
}
|
|
}
|
|
|
|
interface SourceEmailAttachmentItem {
|
|
key: string
|
|
name: string
|
|
meta: string
|
|
}
|
|
|
|
const sourceEmailAttachmentPathTokens = ['attachment', 'attachments', 'file_reference', 'file_references']
|
|
|
|
function readSourceEmailExcerpt(
|
|
taskDetail: ReservationTaskDetailResult | null,
|
|
values: ReservationRecord,
|
|
): string | null {
|
|
if (!taskDetail) {
|
|
return null
|
|
}
|
|
const fieldExcerpt = stringifySourceEmailText(readFirstEvidenceFieldValue(taskDetail, values, ['relevant_message_excerpt']))
|
|
if (fieldExcerpt) {
|
|
return fieldExcerpt
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readSourceEmailAttachments(
|
|
taskDetail: ReservationTaskDetailResult | null,
|
|
values: ReservationRecord,
|
|
): SourceEmailAttachmentItem[] {
|
|
if (!taskDetail) {
|
|
return []
|
|
}
|
|
const attachmentValues = taskDetail.fields
|
|
.filter((field) => isSourceEmailAttachmentField(field))
|
|
.map((field) => readReservationFieldValue(field, values))
|
|
const seen = new Set<string>()
|
|
return attachmentValues
|
|
.flatMap((value) => normalizeAttachmentValue(value))
|
|
.map((value, index) => toSourceEmailAttachmentItem(value, index))
|
|
.filter((item) => {
|
|
const key = `${item.name}-${item.meta}`
|
|
if (seen.has(key)) {
|
|
return false
|
|
}
|
|
seen.add(key)
|
|
return true
|
|
})
|
|
}
|
|
|
|
function readFirstEvidenceFieldValue(
|
|
taskDetail: ReservationTaskDetailResult,
|
|
values: ReservationRecord,
|
|
pathTokens: string[],
|
|
): unknown {
|
|
const field = taskDetail.fields.find((item) => {
|
|
const fieldPath = item.field_path.toLowerCase()
|
|
const pointer = item.field_pointer?.toLowerCase() ?? ''
|
|
return pathTokens.some((token) => fieldPath.includes(token) || pointer.includes(token.split('.').join('/')))
|
|
})
|
|
return field ? readReservationFieldValue(field, values) : null
|
|
}
|
|
|
|
function isSourceEmailAttachmentField(field: ReservationTaskDetailResult['fields'][number]): boolean {
|
|
const fieldPath = field.field_path.toLowerCase()
|
|
const pointer = field.field_pointer?.toLowerCase() ?? ''
|
|
return sourceEmailAttachmentPathTokens.some((token) => fieldPath.includes(token) || pointer.includes(token))
|
|
}
|
|
|
|
function stringifySourceEmailText(value: unknown): string | null {
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value.trim()
|
|
}
|
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
return String(value)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function normalizeAttachmentValue(value: unknown): unknown[] {
|
|
if (value === null || value === undefined || value === '') {
|
|
return []
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item) => normalizeAttachmentValue(item))
|
|
}
|
|
if (typeof value === 'string') {
|
|
const trimmedValue = value.trim()
|
|
if (!trimmedValue) {
|
|
return []
|
|
}
|
|
const parsedValue = parseMaybeJson(trimmedValue)
|
|
return parsedValue === trimmedValue ? [trimmedValue] : normalizeAttachmentValue(parsedValue)
|
|
}
|
|
return [value]
|
|
}
|
|
|
|
function toSourceEmailAttachmentItem(value: unknown, index: number): SourceEmailAttachmentItem {
|
|
const record = readRecord(value)
|
|
const name = record
|
|
? readFirstString(record, ['name', 'file_name', 'filename', 'original_file_name', 'display_name', 'title'])
|
|
: stringifySourceEmailText(value)
|
|
const rawType = record ? readFirstString(record, ['content_type', 'mime_type', 'media_type', 'file_type', 'type']) : null
|
|
const size = record ? readFirstNumber(record, ['size_bytes', 'file_size', 'size']) : null
|
|
const typeLabel = formatAttachmentTypeLabel(name, rawType)
|
|
const sizeLabel = formatAttachmentSize(size)
|
|
const id = record ? readFirstString(record, ['id', 'media_id', 'file_id', 'attachment_id']) : null
|
|
const displayName = name || t('conversation.unnamedAttachment')
|
|
return {
|
|
key: id ? `${id}-${index}` : `${displayName}-${index}`,
|
|
name: displayName,
|
|
meta: [typeLabel, sizeLabel].filter(Boolean).join(' · '),
|
|
}
|
|
}
|
|
|
|
function parseMaybeJson(value: string): unknown {
|
|
if (!value.startsWith('{') && !value.startsWith('[')) {
|
|
return value
|
|
}
|
|
try {
|
|
return JSON.parse(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
function readFirstNumber(record: ReservationRecord, keys: string[]): number | null {
|
|
for (const key of keys) {
|
|
const value = record[key]
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value
|
|
}
|
|
if (typeof value === 'string' && Number.isFinite(Number(value))) {
|
|
return Number(value)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function formatAttachmentTypeLabel(fileName: string | null, rawType: string | null): string {
|
|
const source = `${rawType ?? ''} ${fileName ?? ''}`.toLowerCase()
|
|
if (source.includes('pdf') || source.endsWith('.pdf')) {
|
|
return 'PDF'
|
|
}
|
|
if (source.includes('spreadsheet') || source.includes('excel') || /\.(xlsx|xls|csv)$/.test(source)) {
|
|
return 'Excel'
|
|
}
|
|
if (source.includes('word') || /\.(docx|doc)$/.test(source)) {
|
|
return 'Word'
|
|
}
|
|
if (source.includes('image') || /\.(png|jpe?g|gif|webp)$/.test(source)) {
|
|
return 'Image'
|
|
}
|
|
if (source.includes('message/rfc822') || source.endsWith('.eml')) {
|
|
return 'EML'
|
|
}
|
|
const extension = fileName?.match(/\.([a-z0-9]{2,8})$/i)?.[1]
|
|
return extension ? extension.toUpperCase() : ''
|
|
}
|
|
|
|
function formatAttachmentSize(size: number | null): string {
|
|
if (!size || size <= 0) {
|
|
return ''
|
|
}
|
|
if (size < 1024) {
|
|
return `${size} B`
|
|
}
|
|
if (size < 1024 * 1024) {
|
|
return `${(size / 1024).toFixed(1)} KB`
|
|
}
|
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
function formatJson(value: unknown): string {
|
|
return JSON.stringify(value ?? {}, null, 2)
|
|
}
|
|
|
|
interface ManualReviewEvidenceCandidate {
|
|
title: string | null
|
|
summary: string | null
|
|
sourceSummary: string | null
|
|
attributes: Array<{
|
|
key: string
|
|
value: string
|
|
}>
|
|
}
|
|
|
|
interface ManualReviewEvidence {
|
|
reasonCode: string | null
|
|
visibleReason: string | null
|
|
missingFields: string[]
|
|
blockingPoints: string[]
|
|
conflictingPoints: string[]
|
|
suggestedHumanActions: string[]
|
|
evidenceToCheck: string[]
|
|
parentIdentityCandidates: ManualReviewEvidenceCandidate[]
|
|
}
|
|
|
|
function buildManualReviewEvidence(
|
|
taskDetail: ReservationTaskDetailResult | null,
|
|
manualReview: ReservationRecord | null | undefined,
|
|
): ManualReviewEvidence | null {
|
|
if (!manualReview || normalizeStableCode(readString(manualReview.reason_code)) !== 'TARGET_OBJECT_UNCLEAR') {
|
|
return null
|
|
}
|
|
return {
|
|
reasonCode: readString(manualReview.reason_code),
|
|
visibleReason: readString(manualReview.visible_reason),
|
|
missingFields: readStringArray(manualReview.missing_fields),
|
|
blockingPoints: readStringArray(manualReview.blocking_points),
|
|
conflictingPoints: readStringArray(manualReview.conflicting_points),
|
|
suggestedHumanActions: readStringArray(manualReview.suggested_human_actions),
|
|
evidenceToCheck: readStringArray(manualReview.evidence_to_check),
|
|
parentIdentityCandidates: readParentIdentityCandidates(taskDetail, manualReview),
|
|
}
|
|
}
|
|
|
|
function readParentIdentityCandidates(
|
|
taskDetail: ReservationTaskDetailResult | null,
|
|
manualReview: ReservationRecord,
|
|
): ManualReviewEvidenceCandidate[] {
|
|
const contextCandidates = [
|
|
readRecord(taskDetail?.context_used)?.parent_identity_candidates,
|
|
readRecord(manualReview.context_used)?.parent_identity_candidates,
|
|
manualReview.parent_identity_candidates,
|
|
]
|
|
const candidateList = contextCandidates.find((item) => Array.isArray(item))
|
|
if (!Array.isArray(candidateList)) {
|
|
return []
|
|
}
|
|
return candidateList
|
|
.map((item) => readRecord(item))
|
|
.filter((item): item is ReservationRecord => item !== null)
|
|
.map((item) => {
|
|
const attributes = Object.entries(item)
|
|
.map(([key, value]) => ({
|
|
key,
|
|
value: stringifyCandidateValue(value),
|
|
}))
|
|
.filter((attribute) => attribute.value !== '')
|
|
return {
|
|
title: readFirstString(item, ['field', 'candidate_field', 'key', 'field_path']),
|
|
summary: readFirstString(item, ['value', 'raw_value', 'normalized_value', 'candidate_value']),
|
|
sourceSummary: readFirstString(item, ['evidence_source', 'source', 'evidence', 'origin']),
|
|
attributes,
|
|
}
|
|
})
|
|
}
|
|
|
|
function readRecord(value: unknown): ReservationRecord | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return null
|
|
}
|
|
return value as ReservationRecord
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === 'string' && value.trim() ? value : null
|
|
}
|
|
|
|
function readFirstString(record: ReservationRecord, keys: string[]): string | null {
|
|
for (const key of keys) {
|
|
const value = readString(record[key])
|
|
if (value) {
|
|
return value
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readStringArray(value: unknown): string[] {
|
|
if (!Array.isArray(value)) {
|
|
return []
|
|
}
|
|
return value.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
|
|
}
|
|
|
|
function stringifyCandidateValue(value: unknown): string {
|
|
if (value === undefined || value === null) {
|
|
return ''
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value
|
|
}
|
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
return String(value)
|
|
}
|
|
return JSON.stringify(value)
|
|
}
|
|
|
|
function formatStringList(items: string[]): string {
|
|
return items.length ? items.join(' / ') : '-'
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.task-detail-panel {
|
|
display: grid;
|
|
gap: 18px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.task-state {
|
|
min-height: 220px;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--th-color-slate-500);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.task-state--error {
|
|
color: var(--th-color-danger);
|
|
}
|
|
|
|
.task-detail-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.task-detail-header h1,
|
|
.task-eyebrow {
|
|
margin: 0;
|
|
}
|
|
|
|
.task-eyebrow {
|
|
color: var(--th-color-blue-600);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.task-detail-header h1 {
|
|
margin-top: 5px;
|
|
color: var(--th-color-slate-900);
|
|
font-size: 22px;
|
|
}
|
|
|
|
.task-contract {
|
|
display: inline-block;
|
|
margin-top: 7px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.task-links {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.task-link {
|
|
color: var(--th-color-blue-600);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.task-link--disabled {
|
|
color: var(--th-color-slate-500);
|
|
}
|
|
|
|
.task-source-email,
|
|
.task-route-info,
|
|
.task-ai-diagnostics {
|
|
display: grid;
|
|
gap: 12px;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-md);
|
|
padding: 16px;
|
|
}
|
|
|
|
.task-source-email {
|
|
gap: 16px;
|
|
background: var(--th-color-white);
|
|
}
|
|
|
|
.task-source-email header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 14px;
|
|
}
|
|
|
|
.task-source-email header p,
|
|
.task-source-email header h2,
|
|
.task-source-email__content h3,
|
|
.task-source-email__content p,
|
|
.task-source-email__content ul {
|
|
margin: 0;
|
|
}
|
|
|
|
.task-source-email header p {
|
|
color: var(--th-color-blue-600);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.task-source-email header h2 {
|
|
margin-top: 6px;
|
|
color: var(--th-color-slate-900);
|
|
font-size: 18px;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.task-route-info {
|
|
background: var(--th-color-slate-50);
|
|
}
|
|
|
|
.task-route-info summary {
|
|
color: var(--th-color-slate-700);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.task-route-info[open] summary {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.task-ai-diagnostics {
|
|
background: var(--th-color-white);
|
|
}
|
|
|
|
.task-ai-diagnostics--error {
|
|
border-color: var(--th-color-danger);
|
|
}
|
|
|
|
.manual-review-evidence {
|
|
display: grid;
|
|
gap: 14px;
|
|
border: 1px solid var(--th-color-warning);
|
|
border-radius: var(--th-radius-md);
|
|
background: color-mix(in srgb, var(--th-color-warning) 8%, var(--th-color-white));
|
|
padding: 16px;
|
|
}
|
|
|
|
.source-only-result {
|
|
display: grid;
|
|
gap: 12px;
|
|
border: 1px solid var(--th-color-blue-100);
|
|
border-radius: var(--th-radius-md);
|
|
background: var(--th-color-blue-100);
|
|
padding: 16px;
|
|
}
|
|
|
|
.task-route-info dl,
|
|
.task-ai-diagnostics h2,
|
|
.task-ai-diagnostics pre,
|
|
.manual-review-evidence h2,
|
|
.manual-review-evidence h3,
|
|
.manual-review-evidence p,
|
|
.manual-review-evidence dl,
|
|
.source-only-result h2,
|
|
.source-only-result p,
|
|
.source-only-result dl {
|
|
margin: 0;
|
|
}
|
|
|
|
.task-ai-diagnostics h2,
|
|
.manual-review-evidence h2,
|
|
.source-only-result h2 {
|
|
color: var(--th-color-slate-900);
|
|
font-size: 15px;
|
|
}
|
|
|
|
.manual-review-evidence header {
|
|
display: grid;
|
|
gap: 6px;
|
|
}
|
|
|
|
.manual-review-evidence header p,
|
|
.manual-review-evidence__empty {
|
|
color: var(--th-color-slate-600);
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.source-only-result header {
|
|
display: grid;
|
|
gap: 6px;
|
|
}
|
|
|
|
.source-only-result header p,
|
|
.source-only-result__empty {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.task-source-email__meta,
|
|
.task-route-info dl,
|
|
.manual-review-evidence > dl {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.source-only-result dl {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.task-source-email__meta dt,
|
|
.task-route-info dt,
|
|
.manual-review-evidence > dl dt {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.task-source-email__meta dd,
|
|
.task-route-info dd,
|
|
.manual-review-evidence > dl dd,
|
|
.source-only-result dd {
|
|
margin: 5px 0 0;
|
|
color: var(--th-color-slate-900);
|
|
font-size: 13px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.task-source-email__content {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr);
|
|
gap: 12px;
|
|
}
|
|
|
|
.task-source-email__content section {
|
|
display: grid;
|
|
gap: 8px;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-slate-50);
|
|
padding: 12px;
|
|
}
|
|
|
|
.task-source-email__content section:only-child {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.task-source-email__content h3 {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.task-source-email__content p {
|
|
color: var(--th-color-slate-800);
|
|
font-size: 13px;
|
|
line-height: 1.65;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.task-source-email__content ul {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
.task-source-email__content li {
|
|
display: flex;
|
|
gap: 8px;
|
|
color: var(--th-color-slate-900);
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.task-source-email__content li i {
|
|
flex: 0 0 auto;
|
|
margin-top: 2px;
|
|
color: var(--th-color-blue-600);
|
|
}
|
|
|
|
.task-source-email__content li span {
|
|
min-width: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.task-source-email__content li small {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.manual-review-evidence__candidates {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.manual-review-evidence__candidates h3 {
|
|
color: var(--th-color-slate-900);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.candidate-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.candidate-item {
|
|
display: grid;
|
|
gap: 4px;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-white);
|
|
padding: 10px;
|
|
}
|
|
|
|
.candidate-item strong {
|
|
color: var(--th-color-slate-900);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.candidate-item small {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.candidate-item__attributes {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 4px;
|
|
margin: 4px 0 0;
|
|
}
|
|
|
|
.candidate-item__attributes div {
|
|
display: grid;
|
|
grid-template-columns: minmax(90px, 0.35fr) minmax(0, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.candidate-item__attributes dt,
|
|
.candidate-item__attributes dd {
|
|
margin: 0;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.candidate-item__attributes dd {
|
|
color: var(--th-color-slate-700);
|
|
}
|
|
|
|
.source-only-result__raw {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.source-only-result pre,
|
|
.task-ai-diagnostics pre {
|
|
max-height: 180px;
|
|
overflow: auto;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-white);
|
|
padding: 10px;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.task-ai-diagnostics article {
|
|
display: grid;
|
|
gap: 8px;
|
|
border-top: 1px solid var(--th-color-slate-200);
|
|
padding-top: 10px;
|
|
}
|
|
|
|
.task-ai-diagnostics article:first-of-type {
|
|
border-top: 0;
|
|
padding-top: 0;
|
|
}
|
|
|
|
.task-ai-diagnostics article p {
|
|
margin: 0;
|
|
color: var(--th-color-slate-600);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.task-alert,
|
|
.task-notice {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border-radius: var(--th-radius-sm);
|
|
padding: 10px 12px;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.task-alert {
|
|
background: var(--th-color-danger-bg);
|
|
color: var(--th-color-danger);
|
|
}
|
|
|
|
.task-notice {
|
|
background: var(--th-color-success-bg);
|
|
color: var(--th-color-success);
|
|
}
|
|
|
|
.task-detail-body {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
|
gap: 18px;
|
|
align-items: start;
|
|
}
|
|
|
|
.task-card-main,
|
|
.task-side-stack {
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
|
|
.task-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
border-top: 1px solid var(--th-color-slate-200);
|
|
padding-top: 16px;
|
|
}
|
|
|
|
.primary-button,
|
|
.secondary-button {
|
|
display: inline-flex;
|
|
min-height: 36px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
border-radius: var(--th-radius-sm);
|
|
padding: 8px 14px;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.primary-button {
|
|
border: 1px solid var(--th-color-blue-600);
|
|
background: var(--th-color-blue-600);
|
|
color: var(--th-color-white);
|
|
}
|
|
|
|
.secondary-button {
|
|
border: 1px solid var(--th-color-slate-200);
|
|
background: var(--th-color-white);
|
|
color: var(--th-color-slate-900);
|
|
}
|
|
|
|
.primary-button:disabled,
|
|
.secondary-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
@media (max-width: 1040px) {
|
|
.task-detail-body {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.task-source-email__meta,
|
|
.task-route-info dl,
|
|
.manual-review-evidence > dl,
|
|
.candidate-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.task-source-email header,
|
|
.task-source-email__content {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.task-source-email header {
|
|
display: grid;
|
|
}
|
|
|
|
.task-source-email__meta,
|
|
.task-route-info dl,
|
|
.manual-review-evidence > dl,
|
|
.candidate-grid,
|
|
.source-only-result dl {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|