|
|
@@ -93,47 +93,60 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 附件(与备用金申请页结构、样式一致) -->
|
|
|
+ <!-- 发票夹:可选关联 + 余额提示(不再本地上传发票) -->
|
|
|
<div class="form-card">
|
|
|
<div class="form-header">
|
|
|
- <h3>附件</h3>
|
|
|
+ <h3>关联发票(可选)</h3>
|
|
|
</div>
|
|
|
- <div class="uploader-slot-btn advance-attachment-upload">
|
|
|
- <van-uploader
|
|
|
- :after-read="onAfterRead"
|
|
|
- :before-read="beforeReadAttachment"
|
|
|
- multiple
|
|
|
- result-type="file"
|
|
|
- :accept="attachmentAccept"
|
|
|
- >
|
|
|
- <button type="button" class="btn btn-primary btn-sm">上传附件</button>
|
|
|
- </van-uploader>
|
|
|
- </div>
|
|
|
- <div v-if="attachment.files.value.length" class="att-grid advance-att-grid">
|
|
|
+ <p class="invoice-balance-tip">
|
|
|
+ 发票余额 <b>¥{{ money(walletBalance) }}</b> · 本次合计
|
|
|
+ <b>¥{{ detailTotalAmount }}</b>
|
|
|
+ <span v-if="balanceInsufficient" class="invoice-balance-tip--warn">(余额不足,无法提交)</span>
|
|
|
+ </p>
|
|
|
+ <button type="button" class="btn btn-primary btn-sm" @click="openInvoicePicker">
|
|
|
+ 从发票夹选择
|
|
|
+ </button>
|
|
|
+ <div v-if="selectedInvoiceIds.length" class="invoice-picked">
|
|
|
<div
|
|
|
- v-for="(file, aidx) in attachment.files.value"
|
|
|
- :key="file.id"
|
|
|
- class="att-img advance-att-tile"
|
|
|
- :title="file.fileName"
|
|
|
- role="button"
|
|
|
- tabindex="0"
|
|
|
- @click="attachment.open(file)"
|
|
|
- @keydown.enter.prevent="attachment.open(file)"
|
|
|
+ v-for="inv in selectedInvoiceRows"
|
|
|
+ :key="inv.id"
|
|
|
+ class="invoice-picked__item"
|
|
|
>
|
|
|
- <img
|
|
|
- v-if="attachment.isImage(file.fileType) && file.previewUrl"
|
|
|
- :src="file.previewUrl"
|
|
|
- :alt="file.fileName"
|
|
|
- />
|
|
|
- <div v-else class="att-file-icon att-doc-thumb">
|
|
|
- <span class="att-doc-thumb__ext">{{ attachment.fileExt(file.fileName) }}</span>
|
|
|
- <span class="att-doc-thumb__name">{{ file.fileName }}</span>
|
|
|
- </div>
|
|
|
- <button type="button" class="del" @click.stop="attachment.remove(aidx)">×</button>
|
|
|
+ <span>¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}</span>
|
|
|
+ <button type="button" class="del" @click="removeInvoice(inv.id)">×</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <p class="form-tip">请先在「我的发票夹」上传发票;提交时按合计扣减余额。</p>
|
|
|
</div>
|
|
|
|
|
|
+ <van-popup v-model:show="showInvoicePicker" position="bottom" round :style="{ height: '70%' }">
|
|
|
+ <div class="invoice-picker">
|
|
|
+ <div class="invoice-picker__title">选择发票</div>
|
|
|
+ <van-checkbox-group v-model="pickerCheckedIds">
|
|
|
+ <van-cell-group>
|
|
|
+ <van-cell
|
|
|
+ v-for="inv in walletInvoices"
|
|
|
+ :key="inv.id"
|
|
|
+ clickable
|
|
|
+ @click="togglePickerId(inv.id)"
|
|
|
+ >
|
|
|
+ <template #title>
|
|
|
+ ¥{{ money(inv.amount) }} · {{ inv.remark || '发票' }}
|
|
|
+ </template>
|
|
|
+ <template #right-icon>
|
|
|
+ <van-checkbox :name="String(inv.id)" @click.stop />
|
|
|
+ </template>
|
|
|
+ </van-cell>
|
|
|
+ </van-cell-group>
|
|
|
+ </van-checkbox-group>
|
|
|
+ <div class="invoice-picker__footer">
|
|
|
+ <button type="button" class="btn btn-primary btn-block" @click="confirmInvoicePicker">
|
|
|
+ 确定(已选 {{ pickerCheckedIds.length }})
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </van-popup>
|
|
|
+
|
|
|
<!-- 报销说明 -->
|
|
|
<div class="form-card">
|
|
|
<div class="form-header">
|
|
|
@@ -228,58 +241,19 @@
|
|
|
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
|
import { showToast, showDialog } from 'vant'
|
|
|
-import { reimburseApi } from '@/api'
|
|
|
+import { invoiceApi, invoiceWalletApi, reimburseApi } from '@/api'
|
|
|
import { BizError } from '@/api/client'
|
|
|
import {
|
|
|
useExpenseItemTree,
|
|
|
formatExpenseName,
|
|
|
type ExpenseItem,
|
|
|
} from '@/hooks/useExpenseItemTree'
|
|
|
-import { useAttachment } from '@/hooks/useAttachment'
|
|
|
import { SUBMIT_APPROVE_BUTTON_COLOR } from '@/constants/dialog'
|
|
|
import BottomActionBar from '@/components/mobile/BottomActionBar.vue'
|
|
|
|
|
|
-const MAX_ATTACHMENTS = 10
|
|
|
-
|
|
|
const router = useRouter()
|
|
|
const route = useRoute()
|
|
|
|
|
|
-/** 与备用金申请附件 accept 一致 */
|
|
|
-const attachmentAccept =
|
|
|
- [
|
|
|
- 'image/*',
|
|
|
- 'application/pdf',
|
|
|
- 'application/msword',
|
|
|
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
- 'application/vnd.ms-excel',
|
|
|
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
- 'application/vnd.ms-powerpoint',
|
|
|
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
|
- 'text/plain',
|
|
|
- 'text/csv',
|
|
|
- 'application/rtf',
|
|
|
- 'application/zip',
|
|
|
- 'application/x-zip-compressed',
|
|
|
- 'application/x-rar-compressed',
|
|
|
- 'application/x-7z-compressed',
|
|
|
- '.pdf',
|
|
|
- '.doc',
|
|
|
- '.docx',
|
|
|
- '.xls',
|
|
|
- '.xlsx',
|
|
|
- '.ppt',
|
|
|
- '.pptx',
|
|
|
- '.txt',
|
|
|
- '.csv',
|
|
|
- '.rtf',
|
|
|
- '.wps',
|
|
|
- '.et',
|
|
|
- '.dps',
|
|
|
- '.zip',
|
|
|
- '.rar',
|
|
|
- '.7z',
|
|
|
- ].join(',')
|
|
|
-
|
|
|
interface DetailLine {
|
|
|
expenseItemId: number | string
|
|
|
amount: string
|
|
|
@@ -299,12 +273,80 @@ const pickingLineIndex = ref(0)
|
|
|
const selectedLeftId = ref<string | null>(null)
|
|
|
|
|
|
const { expenseTree, flatList, load: loadExpenseItems } = useExpenseItemTree()
|
|
|
-const attachment = useAttachment()
|
|
|
|
|
|
-const detailTotalAmount = computed(() => {
|
|
|
- const total = form.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0)
|
|
|
- return total.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
|
-})
|
|
|
+const walletBalance = ref(0)
|
|
|
+const walletInvoices = ref<any[]>([])
|
|
|
+const selectedInvoiceIds = ref<string[]>([])
|
|
|
+const showInvoicePicker = ref(false)
|
|
|
+const pickerCheckedIds = ref<string[]>([])
|
|
|
+
|
|
|
+const detailTotalNumber = computed(() =>
|
|
|
+ form.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0),
|
|
|
+)
|
|
|
+
|
|
|
+const detailTotalAmount = computed(() =>
|
|
|
+ detailTotalNumber.value.toLocaleString('zh-CN', {
|
|
|
+ minimumFractionDigits: 2,
|
|
|
+ maximumFractionDigits: 2,
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+const balanceInsufficient = computed(
|
|
|
+ () => detailTotalNumber.value > 0 && walletBalance.value < detailTotalNumber.value,
|
|
|
+)
|
|
|
+
|
|
|
+const selectedInvoiceRows = computed(() =>
|
|
|
+ walletInvoices.value.filter((inv) => selectedInvoiceIds.value.includes(String(inv.id))),
|
|
|
+)
|
|
|
+
|
|
|
+function money(v: unknown) {
|
|
|
+ return Number(v || 0).toLocaleString('zh-CN', {
|
|
|
+ minimumFractionDigits: 2,
|
|
|
+ maximumFractionDigits: 2,
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshWallet() {
|
|
|
+ try {
|
|
|
+ const [sumRes, invRes]: any[] = await Promise.all([
|
|
|
+ invoiceWalletApi.mineSummary(),
|
|
|
+ invoiceApi.list({ page: 1, pageSize: 200 }),
|
|
|
+ ])
|
|
|
+ walletBalance.value = Number(sumRes?.data?.balance ?? 0)
|
|
|
+ const d = invRes?.data
|
|
|
+ walletInvoices.value = d?.rows ?? d?.items ?? d?.list ?? (Array.isArray(d) ? d : [])
|
|
|
+ } catch {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function openInvoicePicker() {
|
|
|
+ pickerCheckedIds.value = [...selectedInvoiceIds.value]
|
|
|
+ showInvoicePicker.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function togglePickerId(id: string | number) {
|
|
|
+ const sid = String(id)
|
|
|
+ const i = pickerCheckedIds.value.indexOf(sid)
|
|
|
+ if (i >= 0) pickerCheckedIds.value.splice(i, 1)
|
|
|
+ else pickerCheckedIds.value.push(sid)
|
|
|
+}
|
|
|
+
|
|
|
+function confirmInvoicePicker() {
|
|
|
+ selectedInvoiceIds.value = [...pickerCheckedIds.value]
|
|
|
+ showInvoicePicker.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function removeInvoice(id: string | number) {
|
|
|
+ const sid = String(id)
|
|
|
+ selectedInvoiceIds.value = selectedInvoiceIds.value.filter((x) => x !== sid)
|
|
|
+}
|
|
|
+
|
|
|
+function buildInvoicePayload() {
|
|
|
+ const invoiceIds = [...selectedInvoiceIds.value]
|
|
|
+ const invoiceLinks = invoiceIds.map((invoiceId) => ({ invoiceId }))
|
|
|
+ return { invoiceIds, invoiceLinks }
|
|
|
+}
|
|
|
|
|
|
function lineExpenseDisplay(id: number | string): string {
|
|
|
if (id === '' || id == null) return ''
|
|
|
@@ -393,28 +435,6 @@ function removeDetailLine(index: number) {
|
|
|
form.details.splice(index, 1)
|
|
|
}
|
|
|
|
|
|
-function beforeReadAttachment(file: File | File[]) {
|
|
|
- const batch = Array.isArray(file) ? file : [file]
|
|
|
- const remain = MAX_ATTACHMENTS - attachment.files.value.length
|
|
|
- if (remain <= 0) {
|
|
|
- showToast(`最多上传 ${MAX_ATTACHMENTS} 个附件`)
|
|
|
- return false
|
|
|
- }
|
|
|
- if (batch.length > remain) {
|
|
|
- showToast(`还可上传 ${remain} 个附件,超出部分已忽略`)
|
|
|
- return Promise.resolve(batch.slice(0, remain))
|
|
|
- }
|
|
|
- return true
|
|
|
-}
|
|
|
-
|
|
|
-async function onAfterRead(uploadFile: any) {
|
|
|
- const files = Array.isArray(uploadFile) ? uploadFile : [uploadFile]
|
|
|
- for (const f of files) {
|
|
|
- if (!f?.file) continue
|
|
|
- await attachment.upload(f.file, 'reimburse_apply', '')
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
/** 与明细行 * 一致:逐行校验,避免漏填仍被 filter 静默丢弃;失败返回 null */
|
|
|
function validateReimburseApplyForm(): DetailLine[] | null {
|
|
|
for (let i = 0; i < form.details.length; i++) {
|
|
|
@@ -461,6 +481,7 @@ async function onSubmit() {
|
|
|
|
|
|
submitting.value = true
|
|
|
try {
|
|
|
+ const invoicePayload = buildInvoicePayload()
|
|
|
const payload = {
|
|
|
title: form.title || undefined,
|
|
|
remark: form.remark || undefined,
|
|
|
@@ -469,6 +490,7 @@ async function onSubmit() {
|
|
|
amount: Number(d.amount),
|
|
|
remark: String(d.remark).trim(),
|
|
|
})),
|
|
|
+ ...invoicePayload,
|
|
|
}
|
|
|
|
|
|
let id: string
|
|
|
@@ -476,7 +498,6 @@ async function onSubmit() {
|
|
|
if (editingId.value) {
|
|
|
id = editingId.value
|
|
|
await reimburseApi.update(id, payload)
|
|
|
- await attachment.bindNew('reimburse_apply', id)
|
|
|
showToast('保存成功')
|
|
|
} else {
|
|
|
const res: any = await reimburseApi.create(payload)
|
|
|
@@ -484,31 +505,41 @@ async function onSubmit() {
|
|
|
if (!id) {
|
|
|
throw new Error('保存成功但未返回报销单ID')
|
|
|
}
|
|
|
- await attachment.bindNew('reimburse_apply', id)
|
|
|
editingId.value = id
|
|
|
showToast('保存成功')
|
|
|
}
|
|
|
|
|
|
showDialog({
|
|
|
title: isEditingAction ? '保存成功' : '创建成功',
|
|
|
- message: '报销单已保存为草稿',
|
|
|
+ message: balanceInsufficient.value
|
|
|
+ ? '已保存为草稿。发票余额不足,请先充值发票夹后再提交。'
|
|
|
+ : '报销单已保存为草稿',
|
|
|
showCancelButton: true,
|
|
|
confirmButtonText: '提交审批',
|
|
|
confirmButtonColor: SUBMIT_APPROVE_BUTTON_COLOR,
|
|
|
cancelButtonText: '继续编辑',
|
|
|
- }).then(() => {
|
|
|
- // 用户点击"提交审批"
|
|
|
- reimburseApi.submit(id).then(() => {
|
|
|
- showToast('提交成功')
|
|
|
- router.back()
|
|
|
- }).catch((e: any) => {
|
|
|
- if (!(e instanceof BizError)) {
|
|
|
- showToast(e?.message || '提交失败')
|
|
|
+ })
|
|
|
+ .then(() => {
|
|
|
+ if (balanceInsufficient.value) {
|
|
|
+ showToast('余额不足,无法提交')
|
|
|
+ return
|
|
|
}
|
|
|
+ reimburseApi
|
|
|
+ .submit(id, invoicePayload)
|
|
|
+ .then(async () => {
|
|
|
+ showToast('提交成功')
|
|
|
+ await refreshWallet()
|
|
|
+ router.back()
|
|
|
+ })
|
|
|
+ .catch((e: any) => {
|
|
|
+ if (!(e instanceof BizError)) {
|
|
|
+ showToast(e?.message || '提交失败')
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ /* 继续编辑 */
|
|
|
})
|
|
|
- }).catch(() => {
|
|
|
- // 用户点击"继续编辑" — 留在当前页
|
|
|
- })
|
|
|
} catch (e: any) {
|
|
|
if (!(e instanceof BizError)) {
|
|
|
showToast(e?.message || '保存失败')
|
|
|
@@ -522,7 +553,7 @@ async function loadEditData() {
|
|
|
const id = route.query.id
|
|
|
if (!id) {
|
|
|
editingId.value = null
|
|
|
- attachment.clear()
|
|
|
+ selectedInvoiceIds.value = []
|
|
|
return
|
|
|
}
|
|
|
editingId.value = String(id)
|
|
|
@@ -540,7 +571,11 @@ async function loadEditData() {
|
|
|
if (form.details.length === 0) {
|
|
|
form.details = [{ expenseItemId: '', amount: '', remark: '' }]
|
|
|
}
|
|
|
- await attachment.loadList('reimburse_apply', editingId.value)
|
|
|
+ const linked = data.invoices || []
|
|
|
+ selectedInvoiceIds.value = linked.map((x: any) => String(x.id || x.invoiceId)).filter(Boolean)
|
|
|
+ if (data.status && data.status !== 'draft' && data.status !== 'rejected') {
|
|
|
+ showToast('旧草稿若仍含本地发票附件,请改走发票夹关联')
|
|
|
+ }
|
|
|
} catch (e: any) {
|
|
|
showToast(e?.message || '加载数据失败')
|
|
|
}
|
|
|
@@ -551,7 +586,7 @@ watch(
|
|
|
async (id, prev) => {
|
|
|
if (!id) {
|
|
|
editingId.value = null
|
|
|
- attachment.clear()
|
|
|
+ selectedInvoiceIds.value = []
|
|
|
return
|
|
|
}
|
|
|
if (String(id) === String(prev ?? '')) return
|
|
|
@@ -561,7 +596,7 @@ watch(
|
|
|
|
|
|
onMounted(async () => {
|
|
|
sessionStorage.removeItem('bx_edit_resubmit')
|
|
|
- await loadExpenseItems()
|
|
|
+ await Promise.all([loadExpenseItems(), refreshWallet()])
|
|
|
await loadEditData()
|
|
|
if (!editingId.value && !form.title) {
|
|
|
try {
|
|
|
@@ -569,7 +604,9 @@ onMounted(async () => {
|
|
|
if (res.data) {
|
|
|
form.title = typeof res.data === 'string' ? res.data : res.data.title || ''
|
|
|
}
|
|
|
- } catch { /* skip */ }
|
|
|
+ } catch {
|
|
|
+ /* skip */
|
|
|
+ }
|
|
|
}
|
|
|
})
|
|
|
</script>
|
|
|
@@ -615,6 +652,57 @@ onMounted(async () => {
|
|
|
font-size: 16px;
|
|
|
}
|
|
|
|
|
|
+.invoice-balance-tip {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text-secondary);
|
|
|
+ margin: 0 0 10px;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.invoice-balance-tip--warn {
|
|
|
+ color: #dc2626;
|
|
|
+ margin-left: 4px;
|
|
|
+}
|
|
|
+.invoice-picked {
|
|
|
+ margin-top: 10px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.invoice-picked__item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 8px 10px;
|
|
|
+ background: #f8fafc;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.invoice-picked__item .del {
|
|
|
+ border: none;
|
|
|
+ background: transparent;
|
|
|
+ font-size: 18px;
|
|
|
+ color: #94a3b8;
|
|
|
+}
|
|
|
+.form-tip {
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--text-muted);
|
|
|
+}
|
|
|
+.invoice-picker {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+ padding-bottom: env(safe-area-inset-bottom);
|
|
|
+}
|
|
|
+.invoice-picker__title {
|
|
|
+ padding: 14px 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.invoice-picker__footer {
|
|
|
+ margin-top: auto;
|
|
|
+ padding: 12px 16px;
|
|
|
+}
|
|
|
.advance-attachment-upload {
|
|
|
display: flex;
|
|
|
justify-content: flex-start;
|