Files
shanxia2.0/miniprogram/pages/bx/reimburse/detail.vue
T

393 lines
12 KiB
Vue

<template>
<view class="bx-page bx-page--form">
<view class="topbar" :style="safeAreaStyle">
<view class="topbar-left">
<text class="topbar-back" @click="goBack">←</text>
<text class="topbar-title">报销详情</text>
</view>
<view class="topbar-right" />
</view>
<template v-if="detail">
<view class="advance-ticket-shell">
<view class="advance-ticket-stack">
<view class="advance-ticket-top">
<text class="advance-ticket-code">{{ detail.applyCode || '—' }}</text>
<text
class="advance-ticket-status"
:class="ticketHeaderStatusToneClass(detail.status)"
>
{{ statusLabel(detail.status) }}
</text>
</view>
<view class="advance-ticket-tear">
<view class="advance-ticket-tear__line" />
</view>
<view class="advance-ticket-bottom">
<view class="advance-ticket-reason">{{ detail.title || '—' }}</view>
<view class="advance-ticket-amount">{{ formatAmount(detail.amount) }}</view>
</view>
</view>
</view>
<view class="detail-tabs">
<view
class="detail-tab"
:class="{ active: detailTab === 0 }"
@click="detailTab = 0"
>
基础信息
</view>
<view
class="detail-tab"
:class="{ active: detailTab === 1 }"
@click="detailTab = 1"
>
审批信息
</view>
</view>
<view v-if="detailTab === 0" class="page-padded advance-detail-stack">
<view class="detail-info-card">
<view class="adv-field">
<text class="adv-field__label">申请单号</text>
<text class="adv-field__value">{{ detail.applyCode || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">报销标题</text>
<text class="adv-field__value">{{ detail.title || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">申请人</text>
<text class="adv-field__value">{{ detail.applicantName || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">冲抵金额</text>
<text class="adv-field__value">
{{ moneyOrDash(detail.offsetAmount ?? detail.offset_amount) }}
</text>
</view>
<view class="adv-field">
<text class="adv-field__label">实付金额</text>
<text class="adv-field__value">
{{
moneyOrDash(
detail.payAmount ?? detail.actualAmount ?? detail.actual_amount
)
}}
</text>
</view>
<view class="adv-field">
<text class="adv-field__label">当前节点</text>
<text class="adv-field__value">{{ detail.currentNode || '—' }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">申请时间</text>
<text class="adv-field__value">{{ formatApplyDateTime(detail) }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">报销说明</text>
<text class="adv-field__value adv-field__value--multiline">
{{ detail.remark || '—' }}
</text>
</view>
</view>
<view
v-if="detail.details && detail.details.length"
class="detail-info-card"
>
<view class="detail-info-card__title">费用项信息</view>
<view
v-for="(d, idx) in detail.details"
:key="idx"
class="reimburse-detail-group"
>
<view v-if="detail.details.length > 1" class="reimburse-detail-head">
明细 {{ idx + 1 }}
</view>
<view class="adv-field">
<text class="adv-field__label">费用项目</text>
<text class="adv-field__value">{{ resolveExpenseDisplay(d, flatList) }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">金额</text>
<text class="adv-field__value adv-field__value--accent">
¥ {{ formatAmount(d.amount) }}
</text>
</view>
<view v-if="d.remark" class="adv-field">
<text class="adv-field__label">报销事由</text>
<text class="adv-field__value adv-field__value--multiline">{{ d.remark }}</text>
</view>
<view class="adv-field">
<text class="adv-field__label">发生时间</text>
<text class="adv-field__value">{{ d.occurDate || '—' }}</text>
</view>
</view>
<view class="adv-field reimburse-detail-total-row">
<text class="adv-field__label">合计</text>
<text class="adv-field__value adv-field__value--accent">
¥ {{ totalAmount }}
</text>
</view>
</view>
<view class="detail-info-card">
<view class="detail-info-card__title">申请附件</view>
<view v-if="attachments.length" class="att-grid">
<view
v-for="file in attachments"
:key="file.id"
class="att-img"
@click="previewAtt(file)"
>
<image
v-if="file.previewUrl"
:src="file.previewUrl"
mode="aspectFill"
/>
<view v-else class="detail-section-empty" style="border: none; padding: 8px">
文件
</view>
</view>
</view>
<view v-else class="detail-section-empty">暂无申请附件</view>
</view>
</view>
<view v-else class="page-padded advance-detail-stack">
<view class="detail-info-card" style="padding: 14px 16px">
<template v-if="approvals.length">
<view v-for="(a, i) in approvals" :key="a.id ?? i" class="flow-step">
<view class="flow-avatar" :class="avatarClass(a)">
{{ (a.approverName || a.nodeName || a.nodeLabel || '审')[0] }}
</view>
<view class="flow-info">
<view class="flow-name">
{{ a.nodeName || a.nodeLabel || a.approverName || '—' }}
</view>
<view v-if="a.approverName" class="flow-role">{{ a.approverName }}</view>
<view v-if="a.approvalTime" class="flow-time">
{{ formatDateTime(a.approvalTime) }}
</view>
<view v-if="a.approvalOpinion" class="adv-flow-opinion">
{{ a.approvalOpinion }}
</view>
</view>
</view>
</template>
<view v-else class="detail-section-empty">暂无审批记录</view>
</view>
</view>
</template>
<view v-else class="page-body-loading">加载中...</view>
<view v-if="showActions" class="bottom-action-bar">
<template v-if="detail?.status === 'draft'">
<button
class="btn btn-primary"
style="flex: 1"
:disabled="actionLoading.submit"
@click="handleSubmit"
>
提交审批
</button>
<button
class="btn btn-danger"
style="flex: 1"
:disabled="actionLoading.delete"
@click="handleDelete"
>
删除
</button>
</template>
<template v-if="detail?.status === 'rejected'">
<button class="btn btn-primary" style="flex: 1" @click="handleResubmit">
重新提交
</button>
</template>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { PAGE_PATHS } from '@/config/api'
import { reimburseApi, attachmentApi, expenseItemApi } from '@/api/bx'
import {
statusLabel,
ticketHeaderStatusToneClass,
formatAmount,
formatDateTime,
formatApplyDateTime,
normalizeExpenseItem,
flattenExpenseTree,
resolveExpenseDisplay
} from '@/utils/bxFormat'
import { getSafeAreaStyle } from '@/utils/safeArea'
const safeAreaStyle = getSafeAreaStyle()
const detailId = ref('')
const detail = ref(null)
const detailTab = ref(0)
const approvals = ref([])
const attachments = ref([])
const flatList = ref([])
const actionLoading = reactive({ submit: false, delete: false })
const showActions = computed(() => {
if (!detail.value) return false
const status = detail.value.status
return status === 'draft' || status === 'rejected'
})
const totalAmount = computed(() => {
const rows = detail.value?.details || []
const sum = rows.reduce((s, d) => s + Number(d.amount || 0), 0)
return formatAmount(sum)
})
function moneyOrDash(raw) {
if (raw == null || raw === '') return '—'
const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
if (Number.isNaN(n) || n === 0) return '—'
return '¥ ' + formatAmount(n)
}
function goBack() {
uni.navigateBack({ fail: () => uni.redirectTo({ url: PAGE_PATHS.bxReimburseApply }) })
}
function avatarClass(a) {
const s = a.approvalStatus || a.status
if (s === 'approved') return 'av-done'
if (s === 'pending') return 'av-current'
if (s === 'rejected') return 'av-reject'
return 'av-pending'
}
function previewAtt(file) {
if (!file.previewUrl) return
uni.previewImage({ urls: [file.previewUrl], current: file.previewUrl })
}
async function loadExpenseFlat() {
try {
const res = await expenseItemApi.tree()
flatList.value = flattenExpenseTree((res.data || []).map(normalizeExpenseItem))
} catch (e) {
flatList.value = []
}
}
async function loadDetail() {
const id = detailId.value
if (!id) return
try {
const res = await reimburseApi.getById(id)
detail.value = res.data
} catch (e) {
uni.showToast({ title: '加载详情失败', icon: 'none' })
return
}
try {
const histRes = await reimburseApi.getApprovalHistory(id)
const hist = histRes.data || []
if (Array.isArray(hist) && hist.length) {
approvals.value = hist
} else {
const apprRes = await reimburseApi.getApprovals(id)
const nodes = apprRes.data || []
approvals.value = nodes.slice().sort((a, b) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
}
} catch (e) {
try {
const apprRes = await reimburseApi.getApprovals(id)
const nodes = apprRes.data || []
approvals.value = nodes.slice().sort((a, b) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
} catch (err) {
approvals.value = []
}
}
try {
const attRes = await attachmentApi.listByBiz('reimburse_apply', id)
const list = (attRes.data || []).map((a) => ({ ...a, previewUrl: '' }))
await Promise.all(
list
.filter((a) => (a.fileType || '').startsWith('image/'))
.map(async (a) => {
try {
const pRes = await attachmentApi.preview(a.id)
a.previewUrl = pRes.data?.url || ''
} catch (e) {
// skip
}
})
)
attachments.value = list
} catch (e) {
attachments.value = []
}
}
async function handleSubmit() {
if (!detail.value) return
actionLoading.submit = true
try {
await reimburseApi.submit(detail.value.id)
uni.showToast({ title: '提交成功', icon: 'success' })
await loadDetail()
} catch (e) {
// toasted
} finally {
actionLoading.submit = false
}
}
function handleDelete() {
if (!detail.value) return
uni.showModal({
title: '确认删除',
content: '确定要删除该报销申请吗?删除后不可恢复。',
confirmText: '确定删除',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
actionLoading.delete = true
try {
await reimburseApi.delete(detail.value.id)
uni.showToast({ title: '删除成功', icon: 'success' })
setTimeout(() => goBack(), 500)
} catch (e) {
// toasted
} finally {
actionLoading.delete = false
}
}
})
}
function handleResubmit() {
if (!detail.value) return
uni.navigateTo({
url: `${PAGE_PATHS.bxReimburseApplyForm}?id=${detail.value.id}`
})
}
onLoad((query) => {
detailId.value = query?.id ? String(query.id) : ''
loadExpenseFlat()
loadDetail()
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
</style>