接入前端P0页面真实接口
This commit is contained in:
1 parent
5c5b8e33b0
commit
66a613d6cd
45 files changed
+8719
No files matched your search
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<section class="audit-panel">
|
||||
<header>
|
||||
<h2>{{ t('task.audits') }}</h2>
|
||||
<p v-if="!items.length">
|
||||
{{ t('task.noAudits') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol
|
||||
v-if="items.length"
|
||||
class="audit-list"
|
||||
>
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.audit_id"
|
||||
>
|
||||
<span class="audit-dot" />
|
||||
<div>
|
||||
<strong>{{ item.action }}</strong>
|
||||
<small>
|
||||
{{ item.actor_type }} / {{ item.actor_id }} · {{ formatReservationDateTime(item.occurred_at) }}
|
||||
</small>
|
||||
<p v-if="item.reason">
|
||||
{{ item.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ReservationTaskAuditLogResult } from '@/types/reservation'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
defineProps<{
|
||||
items: ReservationTaskAuditLogResult[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.audit-panel header {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.audit-panel h2,
|
||||
.audit-panel p,
|
||||
.audit-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.audit-panel h2 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.audit-panel p {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.audit-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.audit-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.audit-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--th-color-blue-600);
|
||||
}
|
||||
|
||||
.audit-list div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.audit-list strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.audit-list small {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<aside class="evidence-panel">
|
||||
<header>
|
||||
<h2>{{ t('task.evidence') }}</h2>
|
||||
<span>{{ sourceMessage.channel }}</span>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd class="th-code">
|
||||
{{ sourceMessage.source_message_id }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sender</dt>
|
||||
<dd>{{ sourceMessage.sender_identifier }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Subject</dt>
|
||||
<dd>{{ sourceMessage.thread_subject }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Received</dt>
|
||||
<dd>{{ formatReservationDateTime(sourceMessage.payload_received_at) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p>{{ sourceMessage.summary }}</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ReservationSourceMessageSummary } from '@/types/reservation'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
defineProps<{
|
||||
sourceMessage: ReservationSourceMessageSummary
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.evidence-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.evidence-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.evidence-panel h2 {
|
||||
margin: 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.evidence-panel header span {
|
||||
border-radius: 999px;
|
||||
background: var(--th-color-slate-100);
|
||||
color: var(--th-color-slate-700);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.evidence-panel dl {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-panel dl div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.evidence-panel dt {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.evidence-panel dd,
|
||||
.evidence-panel p {
|
||||
margin: 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<section class="opera-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h2>{{ t('task.operaOperations') }}</h2>
|
||||
<p v-if="!operations.length">
|
||||
{{ t('task.noOperations') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="operations.length"
|
||||
class="simulate-toggle"
|
||||
>
|
||||
<input
|
||||
v-model="simulateFailure"
|
||||
type="checkbox"
|
||||
>
|
||||
{{ t('task.simulateFailure') }}
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<p
|
||||
v-if="operationErrorMessage"
|
||||
class="operation-error"
|
||||
>
|
||||
{{ operationErrorMessage }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="operations.length"
|
||||
class="operation-list"
|
||||
>
|
||||
<article
|
||||
v-for="operation in operations"
|
||||
:key="operation.operation_id"
|
||||
class="operation-item"
|
||||
>
|
||||
<div class="operation-item__main">
|
||||
<span class="operation-item__sequence">{{ operation.operation_sequence }}</span>
|
||||
<div>
|
||||
<strong>{{ operation.operation_name }}</strong>
|
||||
<small class="th-code">{{ operation.operation_code }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReservationStatusBadge :status="operation.operation_status" />
|
||||
|
||||
<div class="operation-item__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
:disabled="!executable || busyOperationId === operation.operation_id"
|
||||
:title="t('task.execute')"
|
||||
@click="runOperation(operation.operation_id, 'execute')"
|
||||
>
|
||||
<i
|
||||
class="pi pi-play"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
:disabled="!executable || busyOperationId === operation.operation_id"
|
||||
:title="t('task.retry')"
|
||||
@click="runOperation(operation.operation_id, 'retry')"
|
||||
>
|
||||
<i
|
||||
class="pi pi-refresh"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="operation.last_error_message"
|
||||
class="operation-error"
|
||||
>
|
||||
{{ operation.last_error_message }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import { executeReservationOperaOperation, retryReservationOperaOperation } from '@/services/reservationService'
|
||||
import type { ReservationOperaOperationResult } from '@/types/reservation'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: string
|
||||
operations: ReservationOperaOperationResult[]
|
||||
executable: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
operationUpdated: [operation: ReservationOperaOperationResult]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const simulateFailure = ref(false)
|
||||
const busyOperationId = ref<string | null>(null)
|
||||
const operationErrorMessage = ref('')
|
||||
|
||||
async function runOperation(operationId: string, action: 'execute' | 'retry'): Promise<void> {
|
||||
busyOperationId.value = operationId
|
||||
operationErrorMessage.value = ''
|
||||
try {
|
||||
const request = simulateFailure.value
|
||||
? { simulate_success: false, failure_message: 'Frontend simulated OPERA failure' }
|
||||
: { simulate_success: true }
|
||||
const result =
|
||||
action === 'execute'
|
||||
? await executeReservationOperaOperation(props.taskId, operationId, request)
|
||||
: await retryReservationOperaOperation(props.taskId, operationId, request)
|
||||
emit('operationUpdated', result)
|
||||
} catch (error) {
|
||||
operationErrorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
busyOperationId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.opera-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.opera-panel header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.opera-panel h2,
|
||||
.opera-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.opera-panel h2 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.opera-panel p,
|
||||
.simulate-toggle {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.simulate-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.operation-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.operation-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.operation-item__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.operation-item__main div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.operation-item__main strong,
|
||||
.operation-item__main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.operation-item__sequence {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--th-color-slate-100);
|
||||
color: var(--th-color-slate-700);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.operation-item__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-blue-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.operation-error {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.operation-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="`status-badge--${tone}`"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
status: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const tone = computed(() => {
|
||||
if (['ACTIVE', 'COMPLETED', 'SUCCEEDED'].includes(props.status)) {
|
||||
return 'success'
|
||||
}
|
||||
if (['PENDING_CONFIRM', 'READY', 'PENDING', 'TEMPORARY', 'EXECUTING'].includes(props.status)) {
|
||||
return 'info'
|
||||
}
|
||||
if (['BLOCKED', 'FAILED'].includes(props.status)) {
|
||||
return 'danger'
|
||||
}
|
||||
if (['ENDED', 'LOGIC_DELETED', 'SKIPPED'].includes(props.status)) {
|
||||
return 'neutral'
|
||||
}
|
||||
return 'warning'
|
||||
})
|
||||
|
||||
const label = computed(() => t(`status.${props.status}`, props.status))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
min-width: 54px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge--success {
|
||||
background: var(--th-color-success-bg);
|
||||
color: var(--th-color-success);
|
||||
}
|
||||
|
||||
.status-badge--info {
|
||||
background: var(--th-color-info-bg);
|
||||
color: var(--th-color-info);
|
||||
}
|
||||
|
||||
.status-badge--danger {
|
||||
background: var(--th-color-danger-bg);
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.status-badge--warning {
|
||||
background: var(--th-color-warning-bg);
|
||||
color: var(--th-color-warning);
|
||||
}
|
||||
|
||||
.status-badge--neutral {
|
||||
background: var(--th-color-slate-100);
|
||||
color: var(--th-color-slate-700);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<section class="th-section task-detail-panel">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="task-state"
|
||||
>
|
||||
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">
|
||||
{{ t(`cardType.${detail.task_card_type}`, detail.task_card_type) }}
|
||||
</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
|
||||
class="task-link"
|
||||
:to="`/reservation/orders/${detail.order_id}`"
|
||||
>
|
||||
{{ t('task.viewOrder') }} {{ detail.order_id }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="!detail.source_message_id || !detail.external_conversation_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-evidence">
|
||||
<h2>{{ t('task.evidence') }}</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('task.sourceMessageId') }}</dt>
|
||||
<dd class="th-code">
|
||||
{{ detail.source_message_id || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('task.sourceSubject') }}</dt>
|
||||
<dd>
|
||||
{{ detail.source_subject ?? t('task.sourceSummaryPending') }}
|
||||
</dd>
|
||||
</div>
|
||||
<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>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="detail.availability.blocked"
|
||||
class="task-alert"
|
||||
>
|
||||
<i
|
||||
class="pi pi-lock"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ detail.availability.blocked_reason ?? t('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">
|
||||
<ReservationTaskFieldRenderer
|
||||
v-model="fieldValues"
|
||||
:fields="detail.fields"
|
||||
:read-only="detail.availability.read_only"
|
||||
/>
|
||||
|
||||
<footer class="task-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="!canSave || mutationBusy !== null"
|
||||
@click="saveDraft"
|
||||
>
|
||||
<i
|
||||
class="pi pi-save"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('task.saveDraft') }}
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
:task-id="detail.task_id"
|
||||
:operations="detail.opera_operations"
|
||||
:executable="detail.availability.executable"
|
||||
@operation-updated="replaceOperation"
|
||||
/>
|
||||
<ReservationAuditTimeline :items="audits" />
|
||||
</aside>
|
||||
</div>
|
||||
</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 ReservationOperaPanel from '@/components/reservation/ReservationOperaPanel.vue'
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import ReservationTaskFieldRenderer from '@/components/reservation/ReservationTaskFieldRenderer.vue'
|
||||
import {
|
||||
confirmReservationTask,
|
||||
fetchReservationTaskAudits,
|
||||
fetchReservationTaskDetail,
|
||||
saveReservationTaskDraft,
|
||||
} from '@/services/reservationService'
|
||||
import type {
|
||||
ReservationOperaOperationResult,
|
||||
ReservationRecord,
|
||||
ReservationTaskAuditLogResult,
|
||||
ReservationTaskDetailResult,
|
||||
} from '@/types/reservation'
|
||||
import { buildInitialFieldValues } from '@/utils/reservationFieldRules'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
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 mutationBusy = ref<'save' | 'confirm' | null>(null)
|
||||
|
||||
const canSave = computed(() => Boolean(detail.value?.availability.editable))
|
||||
const canConfirm = computed(() => Boolean(detail.value?.availability.confirmable))
|
||||
|
||||
watch(
|
||||
() => props.taskId,
|
||||
(taskId) => {
|
||||
void loadTask(taskId)
|
||||
},
|
||||
{ immediate: 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),
|
||||
fetchReservationTaskAudits(taskId),
|
||||
])
|
||||
detail.value = nextDetail
|
||||
audits.value = nextAudits.items
|
||||
fieldValues.value = buildInitialFieldValues(nextDetail.fields, nextDetail.draft_payload ?? nextDetail.confirmed_payload)
|
||||
} 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 mutateTaskPayload(action: 'save' | 'confirm'): Promise<void> {
|
||||
if (!detail.value) {
|
||||
return
|
||||
}
|
||||
const taskId = detail.value.task_id
|
||||
mutationBusy.value = action
|
||||
actionErrorMessage.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
const request = {
|
||||
field_values: fieldValues.value,
|
||||
}
|
||||
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,
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
}
|
||||
}
|
||||
</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-evidence {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.task-evidence h2,
|
||||
.task-evidence dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-evidence h2 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.task-evidence dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-evidence dt {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-evidence dd {
|
||||
margin: 5px 0 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.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-evidence dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-evidence dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="field-renderer">
|
||||
<section
|
||||
v-for="group in fieldGroups"
|
||||
:key="group.area"
|
||||
class="field-area"
|
||||
>
|
||||
<h3>{{ group.area }}</h3>
|
||||
|
||||
<div class="field-grid">
|
||||
<label
|
||||
v-for="field in group.fields"
|
||||
:key="field.field_path"
|
||||
class="field-item"
|
||||
:class="{ 'field-item--wide': isTableField(field) || isFileField(field) }"
|
||||
>
|
||||
<span class="field-label">
|
||||
{{ field.display_name }}
|
||||
<sup v-if="isReservationFieldRequired(field)">*</sup>
|
||||
</span>
|
||||
|
||||
<select
|
||||
v-if="isEditable(field) && isSelectField(field)"
|
||||
class="field-control"
|
||||
:value="fieldValue(field)"
|
||||
@change="updateField(field, $event)"
|
||||
>
|
||||
<option
|
||||
v-for="option in parseEnumOptions(field.enum_options)"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
v-else-if="isEditable(field) && isDateField(field)"
|
||||
class="field-control"
|
||||
type="date"
|
||||
:value="fieldValue(field)"
|
||||
@input="updateField(field, $event)"
|
||||
>
|
||||
|
||||
<input
|
||||
v-else-if="isEditable(field) && isNumberField(field)"
|
||||
class="field-control"
|
||||
type="number"
|
||||
:value="fieldValue(field)"
|
||||
@input="updateField(field, $event)"
|
||||
>
|
||||
|
||||
<textarea
|
||||
v-else-if="isEditable(field)"
|
||||
class="field-control field-control--textarea"
|
||||
:value="fieldValue(field)"
|
||||
rows="3"
|
||||
@input="updateField(field, $event)"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-else-if="isFileField(field)"
|
||||
class="field-static field-static--file"
|
||||
>
|
||||
<i
|
||||
class="pi pi-paperclip"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ stringifyReservationValue(fieldValue(field), t('task.emptyValue')) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-else-if="isTableField(field)"
|
||||
class="field-static field-static--table"
|
||||
>
|
||||
{{ stringifyReservationValue(fieldValue(field), t('task.emptyValue')) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="field-static"
|
||||
>
|
||||
{{ stringifyReservationValue(fieldValue(field), t('task.emptyValue')) }}
|
||||
</span>
|
||||
|
||||
<small
|
||||
v-if="field.notes"
|
||||
class="field-note"
|
||||
>{{ field.notes }}</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ReservationRecord, ReservationTaskFieldResult } from '@/types/reservation'
|
||||
import {
|
||||
groupReservationFields,
|
||||
isDateField,
|
||||
isFileField,
|
||||
isNumberField,
|
||||
isReservationFieldEditable,
|
||||
isReservationFieldRequired,
|
||||
isSelectField,
|
||||
isTableField,
|
||||
parseEnumOptions,
|
||||
stringifyReservationValue,
|
||||
} from '@/utils/reservationFieldRules'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ReservationTaskFieldResult[]
|
||||
modelValue: ReservationRecord
|
||||
readOnly: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ReservationRecord]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const fieldGroups = computed(() => groupReservationFields(props.fields, t('task.fieldAreaFallback')))
|
||||
|
||||
function fieldValue(field: ReservationTaskFieldResult): string | number | readonly string[] {
|
||||
const value = props.modelValue[field.field_path] ?? field.value ?? ''
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => stringifyReservationValue(item, t('task.emptyValue')))
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return stringifyReservationValue(value, t('task.emptyValue'))
|
||||
}
|
||||
|
||||
function isEditable(field: ReservationTaskFieldResult): boolean {
|
||||
return isReservationFieldEditable(field, props.readOnly)
|
||||
}
|
||||
|
||||
function updateField(field: ReservationTaskFieldResult, event: Event): void {
|
||||
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[field.field_path]: target.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-renderer {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.field-area {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-area h3 {
|
||||
margin: 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.field-item--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field-label sup {
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.field-control,
|
||||
.field-static {
|
||||
min-height: 38px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
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-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.field-control--textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field-static {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 8px 10px;
|
||||
background: var(--th-color-slate-50);
|
||||
}
|
||||
|
||||
.field-static--file {
|
||||
color: var(--th-color-info);
|
||||
}
|
||||
|
||||
.field-static--table {
|
||||
align-items: flex-start;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.field-note {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="task-queue">
|
||||
<button
|
||||
v-for="task in items"
|
||||
:key="task.task_id"
|
||||
class="task-row"
|
||||
:class="{ 'task-row--active': task.task_id === activeTaskId }"
|
||||
type="button"
|
||||
@click="$emit('openTask', task.task_id)"
|
||||
>
|
||||
<span class="task-row__index">{{ task.queue_sequence ?? '-' }}</span>
|
||||
<span class="task-row__main">
|
||||
<strong>{{ task.card_name ?? task.task_type }}</strong>
|
||||
<small>{{ task.task_type }} / {{ task.task_subtype ?? '-' }}</small>
|
||||
<em v-if="!task.source_message_id || !task.external_conversation_id">
|
||||
{{ t('common.endpointMissing') }}
|
||||
</em>
|
||||
</span>
|
||||
<ReservationStatusBadge :status="task.task_status" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import type { ReservationOrderTaskTimelineItem } from '@/types/reservation'
|
||||
|
||||
defineProps<{
|
||||
items: ReservationOrderTaskTimelineItem[]
|
||||
activeTaskId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
openTask: [taskId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-queue {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-md);
|
||||
background: var(--th-color-white);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-row--active {
|
||||
border-color: var(--th-color-blue-600);
|
||||
box-shadow: 0 0 0 1px var(--th-color-blue-600);
|
||||
}
|
||||
|
||||
.task-row__index {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--th-color-blue-100);
|
||||
color: var(--th-color-blue-600);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.task-row__main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.task-row__main strong,
|
||||
.task-row__main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-row__main strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-row__main small {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-row__main em {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user