This commit is contained in:
2026-10-19 10:23:38 +08:00
parent 4bb21641d9
commit 8ae1e5d0d4
18 changed files with 575 additions and 28 deletions
@@ -15,6 +15,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import TripPlanDialog from '../components/TripPlanDialog.vue'
import TripPickupPanel from '../components/TripPickupPanel.vue'
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
import TripBookingPanel from '../components/TripBookingPanel.vue'
@@ -32,6 +33,7 @@ const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
const offerSettingsOpen = ref(false)
const pickupLocked = ref(false)
const appointmentsOpen = ref(false)
const appointmentsPanel = ref<InstanceType<typeof TripAppointmentPanel> | null>(null)
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
@@ -40,6 +42,7 @@ const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
let generation = 0, inspectionSequence = 0, historySequence = 0
const isPickup = computed(() => detail.value?.trip.tripPurpose === 'PICKUP')
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
const editable = computed(() => planned.value && access.can(permission.tripEdit))
const time = (value: string | null | undefined) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
@@ -58,7 +61,7 @@ const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
{ key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }]
const actionLabels: Record<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
const actionLabels: Record<string, string> = { PICKUP_PLAN: '安排上门取货', PICKUP_DEPART: '取货发车', PICKUP_PICKED: '确认取货', PICKUP_ARRIVED: '确认送仓', PICKUP_COMPLETE: '取货运输结案', CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
function accept(value: TripDetail) {
@@ -67,6 +70,7 @@ function accept(value: TripDetail) {
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
}
async function load() {
if (pickupLocked.value) { ElMessage.warning('请先重试并确认原取货操作'); return }
if (appointmentsPanel.value && !await appointmentsPanel.value.canLeave()) return
const current = ++generation; inspectionSequence++; historySequence++
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = route.query.tab === 'loading' ? 'loading' : 'overview'
@@ -78,7 +82,7 @@ async function load() {
finally { if (current === generation) loading.value = false }
}
async function run(action: string, command: () => Promise<TripDetail>): Promise<boolean> {
if (busy.value) return false
if (busy.value || pickupLocked.value) return false
const current = generation; busy.value = action; operationError.value = null
try { const result = await command(); if (current !== generation) return false; accept(result); return true }
catch (cause) {
@@ -185,28 +189,29 @@ async function loadHistory() {
}
watch(() => route.params.tripNo, load, { immediate: true })
watch(() => route.query.tab, tab => { activeTab.value = tab === 'loading' ? 'loading' : 'overview' })
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
watch(activeTab, tab => { if (tab === 'completion' && !isPickup.value) void inspect(); if (tab === 'history') void loadHistory() })
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</script>
<template>
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(appointmentReturnTarget(route.query) || inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ appointmentReturnTarget(route.query) ? '返回预约工作' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy || pickupLocked" @click="load">刷新车次</el-button><el-button v-if="!isPickup && access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy || pickupLocked" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || pickupLocked || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
<div v-loading="loading"><template v-if="detail">
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [[isPickup ? '入仓仓库' : '起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
</FormSection>
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
<template v-if="activeTab === 'overview'">
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane v-if="!isPickup" name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
<div v-if="activeTab === 'overview' || isPickup" v-show="activeTab === 'overview'">
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel v-if="!pickupLocked" :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
<TripPickupPanel v-if="isPickup" :detail="detail" @saved="accept" @locked="pickupLocked = $event" />
<FormSection v-else class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
</FormSection>
</template>
<FormSection v-show="activeTab === 'overview'" v-if="access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
</div>
<FormSection v-show="activeTab === 'overview'" v-if="!isPickup && access.can('platform:appointment:list')" class="density-form-section" title="客户预约与后送去向">
<el-button v-if="!appointmentsOpen" @click="appointmentsOpen = true">核对客户预约与后送去向</el-button>
<TripAppointmentPanel v-if="appointmentsOpen" ref="appointmentsPanel" :key="detail.trip.tripNo" :dispatches="detail.dispatches" :previous-dispatches="detail.previousDispatches" :history-unavailable-reason="detail.appointmentHistoryUnavailableReason" :can-write="planned && !busy" @updated="refreshLinked" @work="key => router.push({ path: '/appointment-calendar', query: { workItem: key } })" @external="path => router.push(path)" />
</FormSection>
@@ -216,7 +221,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
<template v-if="activeTab === 'completion'">
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<FormSection v-if="!isPickup && detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
</FormSection>
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
@@ -10,6 +10,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import TripPlanDialog from '../components/TripPlanDialog.vue'
import PickupDemandPanel from '../components/PickupDemandPanel.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listTrips, type TripQuery } from '../api/trips'
@@ -17,6 +18,8 @@ import { overseasMasterDataPermissions as permission } from '../permissions'
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const workTab = ref('trips'), pickupVisited = ref(false)
watch(workTab, value => { if (value === 'pickups') pickupVisited.value = true })
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
@@ -24,7 +27,7 @@ const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: Object.entries(tripStatusLabels).map(([value, label]) => ({ value, label })) }]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 215 },
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'purpose', label: '任务类型', slotName: 'purpose', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'vehiclePlate', label: '车牌', minWidth: 130 }, { key: 'driverName', label: '司机', minWidth: 110 },
{ key: 'departure', label: '计划发车', slotName: 'departure', minWidth: 175 }, { key: 'end', label: '司机预计结束', slotName: 'end', minWidth: 175 },
{ key: 'route', label: '路线', slotName: 'route', minWidth: 240 }, { key: 'remark', label: '备注', minWidth: 180 },
@@ -46,12 +49,15 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="车次管理">
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
<el-tabs v-model="workTab" class="density-element-tabs"><el-tab-pane label="车次管理" name="trips" /><el-tab-pane label="待取货" name="pickups" /></el-tabs>
<PickupDemandPanel v-if="pickupVisited" v-show="workTab === 'pickups'" />
<DataTable v-show="workTab === 'trips'" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>车次管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" :loading="loading" @search="search" @reset="search" /></template>
<template #notices><LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" @click="view(row.tripNo)">详情</el-button></div></template>
<template #purpose="{ row }">{{ row.tripPurpose === 'PICKUP' ? '入仓取货' : '出库派送' }}</template>
<template #status="{ row }">{{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}</template>
<template #departure="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #end="{ row }">{{ time(row.expectedDriverEndAt) }}</template>
<template #route="{ row }">{{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}</template>