659 lines
18 KiB
Vue
659 lines
18 KiB
Vue
<template>
|
|
<div class="th-page task-list-view">
|
|
<header class="page-heading">
|
|
<div>
|
|
<h1>{{ t('taskList.title') }}</h1>
|
|
<p>{{ t('taskList.subtitle') }}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="th-section filter-section">
|
|
<div class="th-section-header">
|
|
<h2 class="th-section-title">
|
|
{{ t('workbench.filters') }}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
class="text-button"
|
|
@click="resetFilters"
|
|
>
|
|
{{ t('workbench.reset') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="filter-grid">
|
|
<label>
|
|
<span>{{ t('taskList.keyword') }}</span>
|
|
<input
|
|
v-model="filters.keyword"
|
|
type="search"
|
|
:placeholder="t('taskList.keywordPlaceholder')"
|
|
@input="resetToFirstPage"
|
|
>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('taskList.itemType') }}</span>
|
|
<select
|
|
v-model="filters.item_type"
|
|
name="item_type"
|
|
@change="changeItemType"
|
|
>
|
|
<option value="">{{ t('common.all') }}</option>
|
|
<option value="ORDER_TASK">{{ t('taskList.itemTypeOrderTask') }}</option>
|
|
<option value="SOURCE_NOTIFICATION">{{ t('taskList.itemTypeSourceNotification') }}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('taskList.orderTaskStatus') }}</span>
|
|
<select
|
|
v-model="filters.order_task_status"
|
|
name="order_task_status"
|
|
:disabled="!isOrderTaskMode"
|
|
@change="resetToFirstPage"
|
|
>
|
|
<option value="">{{ t('common.all') }}</option>
|
|
<option value="OPEN">{{ t('status.OPEN') }}</option>
|
|
<option value="COMPLETED">{{ t('status.COMPLETED') }}</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>{{ t('taskList.cardStatus') }}</span>
|
|
<select
|
|
v-model="filters.card_status"
|
|
name="card_status"
|
|
:disabled="!isOrderTaskMode"
|
|
@change="resetToFirstPage"
|
|
>
|
|
<option value="">{{ t('common.all') }}</option>
|
|
<option value="PENDING_CONFIRM">{{ t('status.PENDING_CONFIRM') }}</option>
|
|
<option value="REVIEW_REQUIRED">{{ t('status.REVIEW_REQUIRED') }}</option>
|
|
<option value="CONFIRMED">{{ t('status.CONFIRMED') }}</option>
|
|
<option value="READONLY">{{ t('status.READONLY') }}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="th-section table-section">
|
|
<div class="th-section-header">
|
|
<div>
|
|
<h2 class="th-section-title">
|
|
{{ t('taskList.tableTitle') }}
|
|
</h2>
|
|
<p class="th-muted">
|
|
{{ t('workbench.totalPrefix') }} {{ total }} {{ t('taskList.totalSuffix') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="loading"
|
|
class="empty-state"
|
|
>
|
|
{{ t('common.loading') }}
|
|
</div>
|
|
<div
|
|
v-else-if="errorMessage"
|
|
class="empty-state empty-state--error"
|
|
>
|
|
{{ errorMessage }}
|
|
</div>
|
|
<div
|
|
v-else-if="!items.length"
|
|
class="empty-state"
|
|
>
|
|
{{ t('common.noData') }}
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="table-wrap"
|
|
>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('taskList.columns.item') }}</th>
|
|
<th>{{ t('taskList.columns.order') }}</th>
|
|
<th>{{ t('taskList.columns.status') }}</th>
|
|
<th>{{ t('taskList.columns.source') }}</th>
|
|
<th>{{ t('taskList.columns.cards') }}</th>
|
|
<th>{{ t('workbench.columns.operation') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="item in items"
|
|
:key="`${item.item_type}-${item.target_id}`"
|
|
>
|
|
<td>
|
|
<strong>{{ itemKindLabel(item) }}</strong>
|
|
<small class="th-code">#{{ item.target_id }}</small>
|
|
</td>
|
|
<td>
|
|
<template v-if="item.item_type === 'ORDER_TASK'">
|
|
<strong>{{ item.display_order_key ?? t('taskList.orderUnresolved') }}</strong>
|
|
<small
|
|
v-if="item.order_id"
|
|
class="th-code"
|
|
>{{ item.order_id }}</small>
|
|
</template>
|
|
<template v-else>
|
|
<strong>{{ t('taskList.sourceNotificationNoOrder') }}</strong>
|
|
<small>{{ t('taskList.sourceNotificationNoOrderHint') }}</small>
|
|
</template>
|
|
</td>
|
|
<td>
|
|
<ReservationStatusBadge :status="displayStatus(item)" />
|
|
<small v-if="item.readonly_reason_code">
|
|
{{ formatReservationReadonlyReason(t, item.readonly_reason_code) }}
|
|
</small>
|
|
</td>
|
|
<td>
|
|
<span>{{ sourceSubject(item) }}</span>
|
|
<small>{{ sourceSender(item) }}</small>
|
|
<small>{{ t('taskList.sourceReceivedAt') }}: {{ formatReservationDateTime(sourceReceivedAt(item)) }}</small>
|
|
</td>
|
|
<td>
|
|
<template v-if="item.card_counts">
|
|
<span>{{ t('taskList.cardCountsTotal', { total: item.card_counts.total_count }) }}</span>
|
|
<small>
|
|
{{ t('taskList.cardCountsPending', { count: item.card_counts.pending_confirm_count }) }}
|
|
·
|
|
{{ t('taskList.cardCountsReview', { count: item.card_counts.review_required_count }) }}
|
|
·
|
|
{{ t('taskList.cardCountsConfirmed', { count: item.card_counts.confirmed_count }) }}
|
|
</small>
|
|
</template>
|
|
<template v-else>
|
|
<span>{{ item.notification_status ? formatStatusLabel(item.notification_status) : '-' }}</span>
|
|
</template>
|
|
</td>
|
|
<td>
|
|
<div class="row-actions">
|
|
<RouterLink
|
|
class="row-action"
|
|
:to="detailPath(item)"
|
|
>
|
|
{{ item.item_type === 'SOURCE_NOTIFICATION' ? t('taskList.viewNotification') : t('taskList.viewTask') }}
|
|
</RouterLink>
|
|
<RouterLink
|
|
v-if="item.order_id"
|
|
class="row-action"
|
|
:to="`/reservation/orders/${item.order_id}`"
|
|
>
|
|
{{ t('taskList.viewOrder') }}
|
|
</RouterLink>
|
|
<span
|
|
v-if="!item.source_message_id"
|
|
class="pending-link"
|
|
>
|
|
{{ t('common.endpointMissing') }}
|
|
</span>
|
|
<RouterLink
|
|
v-else
|
|
class="row-action"
|
|
:to="`/reservation/source-messages/${item.source_message_id}/conversation`"
|
|
>
|
|
{{ t('taskList.viewConversation') }}
|
|
</RouterLink>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<footer class="pagination-bar">
|
|
<label class="page-size-control">
|
|
<span>{{ t('common.pageSize') }}</span>
|
|
<select
|
|
v-model.number="filters.page_size"
|
|
@change="resetToFirstPage"
|
|
>
|
|
<option :value="10">10</option>
|
|
<option :value="20">20</option>
|
|
<option :value="50">50</option>
|
|
</select>
|
|
</label>
|
|
<div class="pagination-actions">
|
|
<button
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="currentPage <= 1 || loading"
|
|
@click="goToPage(currentPage - 1)"
|
|
>
|
|
{{ t('common.previousPage') }}
|
|
</button>
|
|
<span>{{ t('common.pageStatus', { current: currentPage, total: totalPages }) }}</span>
|
|
<button
|
|
type="button"
|
|
class="secondary-button"
|
|
:disabled="currentPage >= totalPages || loading"
|
|
@click="goToPage(currentPage + 1)"
|
|
>
|
|
{{ t('common.nextPage') }}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { RouterLink } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
|
import {
|
|
fetchReservationV4OrderTasks,
|
|
fetchReservationV4WorkbenchItems,
|
|
} from '@/services/reservationService'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import type {
|
|
ReservationPageResult,
|
|
ReservationV4OrderTaskListFilters,
|
|
ReservationV4OrderTaskListItem,
|
|
ReservationV4WorkbenchFilters,
|
|
ReservationV4WorkbenchItem,
|
|
ReservationV4WorkbenchItemType,
|
|
} from '@/types/reservation'
|
|
import {
|
|
formatReservationReadonlyReason,
|
|
} from '@/utils/reservationDisplay'
|
|
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
|
|
|
type WorkbenchItemFilter = ReservationV4WorkbenchItemType | ''
|
|
type WorkbenchRow = ReservationV4WorkbenchItem & {
|
|
order_id?: string | null
|
|
}
|
|
|
|
const { t } = useI18n()
|
|
const authStore = useAuthStore()
|
|
const loading = ref(false)
|
|
const errorMessage = ref('')
|
|
const items = ref<WorkbenchRow[]>([])
|
|
const page = ref(createPage(1, 20, 0))
|
|
const total = computed(() => page.value.total)
|
|
const currentPage = computed(() => page.value.page_num)
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(page.value.total / Math.max(page.value.page_size, 1))))
|
|
const isOrderTaskMode = computed(() => filters.item_type === 'ORDER_TASK')
|
|
let taskListRequestSequence = 0
|
|
|
|
const filters = reactive<{
|
|
keyword: string
|
|
item_type: WorkbenchItemFilter
|
|
order_task_status: '' | 'OPEN' | 'COMPLETED'
|
|
card_status: '' | 'READONLY' | 'PENDING_CONFIRM' | 'REVIEW_REQUIRED' | 'CONFIRMED'
|
|
page_num: number
|
|
page_size: number
|
|
}>({
|
|
keyword: '',
|
|
item_type: '',
|
|
order_task_status: '',
|
|
card_status: '',
|
|
page_num: 1,
|
|
page_size: 20,
|
|
})
|
|
|
|
onMounted(() => {
|
|
void loadTasks()
|
|
})
|
|
|
|
watch(filters, () => {
|
|
void loadTasks()
|
|
})
|
|
|
|
watch(
|
|
() => authStore.selectedHotelId,
|
|
(nextHotelId, previousHotelId) => {
|
|
if (!nextHotelId || nextHotelId === previousHotelId) {
|
|
return
|
|
}
|
|
reloadTasksForHotelChange()
|
|
},
|
|
)
|
|
|
|
async function loadTasks(): Promise<void> {
|
|
const requestSequence = ++taskListRequestSequence
|
|
loading.value = true
|
|
errorMessage.value = ''
|
|
try {
|
|
const result = isOrderTaskMode.value
|
|
? await fetchReservationV4OrderTasks(buildOrderTaskFilters())
|
|
: await fetchReservationV4WorkbenchItems(buildWorkbenchFilters())
|
|
if (requestSequence !== taskListRequestSequence) {
|
|
return
|
|
}
|
|
items.value = result.items.map((item) => 'order_task' in item ? toWorkbenchRowFromOrderTask(item) : item)
|
|
page.value = result.page
|
|
} catch (error) {
|
|
if (requestSequence !== taskListRequestSequence) {
|
|
return
|
|
}
|
|
items.value = []
|
|
page.value = createPage(filters.page_num, filters.page_size, 0)
|
|
errorMessage.value = error instanceof Error ? error.message : String(error)
|
|
} finally {
|
|
if (requestSequence === taskListRequestSequence) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildWorkbenchFilters(): ReservationV4WorkbenchFilters {
|
|
return {
|
|
keyword: filters.keyword,
|
|
item_type: filters.item_type,
|
|
page_num: filters.page_num,
|
|
page_size: filters.page_size,
|
|
}
|
|
}
|
|
|
|
function buildOrderTaskFilters(): ReservationV4OrderTaskListFilters {
|
|
return {
|
|
keyword: filters.keyword,
|
|
order_task_status: filters.order_task_status,
|
|
card_status: filters.card_status,
|
|
page_num: filters.page_num,
|
|
page_size: filters.page_size,
|
|
}
|
|
}
|
|
|
|
function toWorkbenchRowFromOrderTask(item: ReservationV4OrderTaskListItem): WorkbenchRow {
|
|
return {
|
|
item_type: 'ORDER_TASK',
|
|
target_id: item.order_task.order_task_id,
|
|
hotel_id: item.order_task.hotel_id,
|
|
source_message_id: item.order_task.source_message_id,
|
|
source_message_summary: item.source_message_summary,
|
|
source_received_at: item.order_task.source_received_at,
|
|
display_order_key: item.order_task.display_order_key,
|
|
card_counts: item.card_counts,
|
|
next_action_card_id: item.next_action_card_id,
|
|
notification_status: null,
|
|
order_task_status: item.order_task.order_task_status,
|
|
display_status: item.display_status,
|
|
readonly_reason_code: item.availability.readonly_reason_code,
|
|
created_at: item.order_task.created_at,
|
|
updated_at: item.order_task.updated_at,
|
|
order_id: item.order_task.order_id,
|
|
}
|
|
}
|
|
|
|
function resetFilters(): void {
|
|
filters.keyword = ''
|
|
filters.item_type = ''
|
|
filters.order_task_status = ''
|
|
filters.card_status = ''
|
|
filters.page_num = 1
|
|
filters.page_size = 20
|
|
}
|
|
|
|
function changeItemType(): void {
|
|
if (!isOrderTaskMode.value) {
|
|
filters.order_task_status = ''
|
|
filters.card_status = ''
|
|
}
|
|
resetToFirstPage()
|
|
}
|
|
|
|
function resetToFirstPage(): void {
|
|
filters.page_num = 1
|
|
}
|
|
|
|
function reloadTasksForHotelChange(): void {
|
|
if (filters.page_num !== 1) {
|
|
filters.page_num = 1
|
|
return
|
|
}
|
|
void loadTasks()
|
|
}
|
|
|
|
function goToPage(page: number): void {
|
|
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
|
}
|
|
|
|
function itemKindLabel(item: WorkbenchRow): string {
|
|
return item.item_type === 'SOURCE_NOTIFICATION'
|
|
? t('taskList.itemTypeSourceNotification')
|
|
: t('taskList.itemTypeOrderTask')
|
|
}
|
|
|
|
function displayStatus(item: WorkbenchRow): string {
|
|
return item.display_status ?? item.notification_status ?? item.order_task_status ?? 'OPEN'
|
|
}
|
|
|
|
function formatStatusLabel(status: string): string {
|
|
return t(`status.${status}`, status)
|
|
}
|
|
|
|
function detailPath(item: WorkbenchRow): string {
|
|
if (item.item_type === 'SOURCE_NOTIFICATION') {
|
|
return `/reservation/source-notifications/${item.target_id}`
|
|
}
|
|
return `/reservation/order-tasks/${item.target_id}`
|
|
}
|
|
|
|
function sourceSubject(item: WorkbenchRow): string {
|
|
return item.source_message_summary?.subject ?? t('taskList.noSourceSubject')
|
|
}
|
|
|
|
function sourceSender(item: WorkbenchRow): string {
|
|
return item.source_message_summary?.sender_summary ?? item.source_message_id ?? '-'
|
|
}
|
|
|
|
function sourceReceivedAt(item: WorkbenchRow): string | null {
|
|
return item.source_received_at ?? item.source_message_summary?.received_at ?? null
|
|
}
|
|
|
|
function createPage(pageNum: number, pageSize: number, total: number): ReservationPageResult {
|
|
return {
|
|
page_num: pageNum,
|
|
page_size: pageSize,
|
|
total,
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.task-list-view {
|
|
max-width: 1320px;
|
|
}
|
|
|
|
.page-heading h1,
|
|
.page-heading p {
|
|
margin: 0;
|
|
}
|
|
|
|
.page-heading h1 {
|
|
color: var(--th-color-slate-900);
|
|
font-size: 28px;
|
|
}
|
|
|
|
.page-heading p {
|
|
margin-top: 8px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.filter-section {
|
|
padding-bottom: 20px;
|
|
}
|
|
|
|
.filter-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 14px;
|
|
padding: 18px 20px 0;
|
|
}
|
|
|
|
.filter-grid label {
|
|
display: grid;
|
|
gap: 7px;
|
|
}
|
|
|
|
.filter-grid span {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.filter-grid input,
|
|
.filter-grid select {
|
|
min-height: 38px;
|
|
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);
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.filter-grid select:disabled {
|
|
color: var(--th-color-slate-500);
|
|
cursor: not-allowed;
|
|
opacity: 0.72;
|
|
}
|
|
|
|
.text-button,
|
|
.row-action {
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--th-color-blue-600);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.empty-state {
|
|
min-height: 220px;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 13px;
|
|
padding: 24px;
|
|
}
|
|
|
|
.empty-state--error {
|
|
color: var(--th-color-danger);
|
|
}
|
|
|
|
.table-section {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-wrap {
|
|
overflow-x: auto;
|
|
padding: 14px 20px 20px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
min-width: 980px;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th,
|
|
td {
|
|
border-bottom: 1px solid var(--th-color-slate-200);
|
|
padding: 14px 10px;
|
|
text-align: left;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
th {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
td {
|
|
color: var(--th-color-slate-900);
|
|
font-size: 13px;
|
|
}
|
|
|
|
td strong,
|
|
td small {
|
|
display: block;
|
|
}
|
|
|
|
td small {
|
|
margin-top: 4px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.row-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.pending-link {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.pagination-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 14px;
|
|
border-top: 1px solid var(--th-color-slate-200);
|
|
padding: 14px 20px 18px;
|
|
}
|
|
|
|
.page-size-control,
|
|
.pagination-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.page-size-control span,
|
|
.pagination-actions span {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.page-size-control select {
|
|
min-height: 34px;
|
|
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);
|
|
padding: 6px 8px;
|
|
}
|
|
|
|
.secondary-button {
|
|
min-height: 34px;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-white);
|
|
color: var(--th-color-slate-700);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
padding: 6px 12px;
|
|
}
|
|
|
|
.secondary-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.filter-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.pagination-bar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|