<!--OA模块:差旅报销-->
|
<template>
|
<div class="app-container">
|
<div class="search_form mb20">
|
<div>
|
<span class="search_title">申请人:</span>
|
<el-input
|
v-model="searchForm.applicantKeyword"
|
style="width: 220px"
|
placeholder="姓名或编号"
|
clearable
|
:prefix-icon="Search"
|
@keyup.enter="handleQuery"
|
/>
|
<span class="search_title" style="margin-left: 12px">出差开始:</span>
|
<el-date-picker
|
v-model="searchForm.travelStartFrom"
|
type="date"
|
placeholder="开始日期"
|
format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
style="width: 150px"
|
clearable
|
/>
|
<span class="search_title" style="margin-left: 8px">结束:</span>
|
<el-date-picker
|
v-model="searchForm.travelEndTo"
|
type="date"
|
placeholder="结束日期"
|
format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
style="width: 150px"
|
clearable
|
/>
|
<el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
|
<el-button @click="resetSearch">重置</el-button>
|
</div>
|
<div class="search_actions">
|
<el-button type="success" plain @click="handleImportClick">导入</el-button>
|
<el-button type="warning" plain @click="handleExport">导出</el-button>
|
<el-button type="primary" @click="openFormDialog('add')">新增差旅报销</el-button>
|
</div>
|
</div>
|
|
<input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
|
|
<div class="table_list">
|
<PIMTable
|
rowKey="id"
|
:column="tableColumn"
|
:tableData="tableData"
|
:page="page"
|
:isSelection="false"
|
:tableLoading="tableLoading"
|
:total="page.total"
|
@pagination="pagination"
|
/>
|
</div>
|
|
<!-- 新增 / 编辑 -->
|
<el-dialog
|
v-model="formDialog.visible"
|
:title="formDialog.title"
|
width="1120px"
|
append-to-body
|
destroy-on-close
|
class="travel-reimburse-form-dialog"
|
@closed="onFormClosed"
|
>
|
<el-alert
|
v-if="budgetHint.visible"
|
:title="budgetHint.title"
|
:type="budgetHint.type"
|
:description="budgetHint.description"
|
show-icon
|
:closable="false"
|
class="mb16"
|
/>
|
<el-alert v-if="overBudgetWarnings.length" type="warning" show-icon :closable="false" class="mb16">
|
<template #title>差旅标准超支提醒(需特批)</template>
|
<ul class="warn-list">
|
<li v-for="(w, i) in overBudgetWarnings" :key="i">{{ w }}</li>
|
</ul>
|
</el-alert>
|
|
<el-form
|
ref="formRef"
|
:model="form"
|
:rules="formRules"
|
label-width="120px"
|
class="travel-reimburse-form"
|
:disabled="formDialog.readonly"
|
>
|
<el-card class="form-section" shadow="never">
|
<template #header><span class="card-header-title">基本信息</span></template>
|
<el-row :gutter="20">
|
<el-col :span="12">
|
<el-form-item label="员工编号">
|
<el-input v-model="form.employeeNo" readonly placeholder="选择员工后自动带出" />
|
</el-form-item>
|
</el-col>
|
<el-col :span="12">
|
<el-form-item label="员工姓名" prop="applicantId">
|
<el-select
|
v-model="form.applicantId"
|
filterable
|
remote
|
clearable
|
reserve-keyword
|
placeholder="请选择或搜索员工"
|
style="width: 100%"
|
:remote-method="remoteSearchApplicantForm"
|
:loading="applicantFormSearchLoading"
|
@change="onApplicantChange"
|
>
|
<el-option
|
v-for="u in applicantFormOptions"
|
:key="u.userId"
|
:label="userSelectLabel(u)"
|
:value="u.userId"
|
/>
|
</el-select>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="24">
|
<el-form-item label="报销原因" prop="reimburseReason">
|
<el-input
|
v-model="form.reimburseReason"
|
type="textarea"
|
:rows="3"
|
placeholder="请填写出差及报销原因"
|
maxlength="2000"
|
show-word-limit
|
/>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="12">
|
<el-form-item label="出差开始" prop="travelStartTime">
|
<el-date-picker
|
v-model="form.travelStartTime"
|
type="datetime"
|
placeholder="开始时间"
|
format="YYYY-MM-DD HH:mm:ss"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
style="width: 100%"
|
@change="onTravelRangeChange"
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="12">
|
<el-form-item label="出差结束" prop="travelEndTime">
|
<el-date-picker
|
v-model="form.travelEndTime"
|
type="datetime"
|
placeholder="结束时间"
|
format="YYYY-MM-DD HH:mm:ss"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
style="width: 100%"
|
@change="onTravelRangeChange"
|
/>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="8">
|
<el-form-item label="出差天数">
|
<el-input :model-value="travelDaysDisplay" readonly>
|
<template #append>天</template>
|
</el-input>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="出差地" prop="departurePlace">
|
<el-input v-model="form.departurePlace" placeholder="出发城市" @blur="recalcTravelStandards" />
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="目的地" prop="destination">
|
<el-input v-model="form.destination" placeholder="目的城市" @blur="recalcTravelStandards" />
|
</el-form-item>
|
</el-col>
|
</el-row>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header>
|
<div class="card-header-row">
|
<span class="card-header-title">差旅标准</span>
|
<el-text type="info" size="small">{{ travelTierLabel }} · 生活补贴建议 {{ suggestedLivingSubsidy }} 元</el-text>
|
</div>
|
</template>
|
<el-row :gutter="20">
|
<el-col :span="8">
|
<el-form-item label="酒店标准">
|
<el-input-number
|
v-model="form.hotelStandard"
|
:min="0"
|
:precision="2"
|
controls-position="right"
|
style="width: 100%"
|
@change="recalcTravelStandards"
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="住宿天数">
|
<el-input-number
|
v-model="form.hotelDays"
|
:min="0"
|
:max="365"
|
:precision="0"
|
controls-position="right"
|
style="width: 100%"
|
@change="recalcTravelStandards"
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="生活补贴">
|
<el-input-number
|
v-model="form.livingSubsidy"
|
:min="0"
|
:precision="2"
|
controls-position="right"
|
style="width: 100%"
|
@change="recalcTravelStandards"
|
/>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-row :gutter="20">
|
<el-col :span="8">
|
<el-form-item label="交通补贴">
|
<el-input :model-value="String(suggestedTransportSubsidy)" readonly><template #append>元</template></el-input>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="住宿限额">
|
<el-input :model-value="String(suggestedHotelLimit)" readonly><template #append>元</template></el-input>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="特批标记">
|
<el-tag :type="form.needSpecialApproval ? 'danger' : 'success'" effect="plain">
|
{{ form.needSpecialApproval ? "超支需特批" : "在标准范围内" }}
|
</el-tag>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header><span class="card-header-title">金额与收款</span></template>
|
<el-row :gutter="20">
|
<el-col :span="12">
|
<el-form-item label="申请金额" prop="applyAmount">
|
<div class="amount-row">
|
<el-input-number v-model="form.applyAmount" :min="0" :precision="2" controls-position="right" class="amount-input" />
|
<el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
|
按明细汇总 {{ detailTotalAmount }} 元
|
</el-button>
|
</div>
|
</el-form-item>
|
</el-col>
|
<el-col :span="12">
|
<el-form-item label="收款人" prop="payee">
|
<el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" />
|
</el-form-item>
|
</el-col>
|
</el-row>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header>
|
<div class="card-header-row">
|
<span class="card-header-title">报销明细</span>
|
<el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">新增明细</el-button>
|
</div>
|
</template>
|
|
<el-table :data="form.expenseDetails" border size="small" class="detail-table">
|
<el-table-column type="index" label="序号" width="55" align="center" />
|
<el-table-column label="发票日期" width="150">
|
<template #default="{ row }">
|
<el-date-picker
|
v-if="!formDialog.readonly"
|
v-model="row.invoiceDate"
|
type="date"
|
value-format="YYYY-MM-DD"
|
size="small"
|
style="width: 100%"
|
/>
|
<span v-else>{{ row.invoiceDate || "—" }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="费用科目" width="130">
|
<template #default="{ row }">
|
<el-select
|
v-if="!formDialog.readonly"
|
v-model="row.expenseSubject"
|
size="small"
|
style="width: 100%"
|
@change="recalcTravelStandards"
|
>
|
<el-option
|
v-for="opt in EXPENSE_SUBJECT_OPTIONS"
|
:key="opt.value"
|
:label="opt.label"
|
:value="opt.value"
|
/>
|
</el-select>
|
<span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="金额" width="120">
|
<template #default="{ row }">
|
<el-input-number
|
v-if="!formDialog.readonly"
|
v-model="row.amount"
|
:min="0"
|
:precision="2"
|
size="small"
|
controls-position="right"
|
style="width: 100%"
|
@change="onDetailAmountChange"
|
/>
|
<span v-else>{{ row.amount ?? "—" }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column label="描述" min-width="140">
|
<template #default="{ row }">
|
<el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说明" />
|
<span v-else>{{ row.description || "—" }}</span>
|
</template>
|
</el-table-column>
|
<el-table-column v-if="!formDialog.readonly" label="操作" width="70" align="center">
|
<template #default="{ $index }">
|
<el-button type="danger" link size="small" @click="removeExpenseDetail($index)">删除</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header><span class="card-header-title">附件(发票)</span></template>
|
<el-form-item label-width="0" class="attachment-form-item">
|
<div class="upload-block">
|
<FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="点击选择文件" />
|
</div>
|
</el-form-item>
|
</el-card>
|
|
<el-card class="form-section" shadow="never">
|
<template #header><span class="card-header-title">审批流程</span></template>
|
<el-form-item prop="approvalFlowNodes" label-width="0">
|
<ApprovalFlowEditor
|
v-if="!formDialog.readonly"
|
v-model="form.approvalFlowNodes"
|
:user-options="flowUserOptions"
|
@update:model-value="onApprovalFlowChange"
|
/>
|
<ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
|
<p v-if="!formDialog.readonly" class="flow-tip">至少保留一个节点;审核中、已通过的单据不可编辑。</p>
|
</el-form-item>
|
</el-card>
|
</el-form>
|
<template #footer>
|
<el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">提 交</el-button>
|
<el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 闭" : "取 消" }}</el-button>
|
</template>
|
</el-dialog>
|
|
<!-- 详情 -->
|
<el-dialog v-model="detailDialog.visible" title="差旅报销详情" width="900px" append-to-body destroy-on-close>
|
<DetailPanel :row="detailRow" />
|
<ApprovalFlowProgress
|
class="mt16"
|
:nodes="detailRow.approvalFlowNodes"
|
:current-index="detailRow.currentNodeIndex ?? 0"
|
/>
|
<el-divider content-position="left">审批记录(全流程留痕)</el-divider>
|
<el-timeline v-if="detailRow.approvalRecords?.length">
|
<el-timeline-item
|
v-for="(rec, i) in detailRow.approvalRecords"
|
:key="i"
|
:type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
|
:timestamp="rec.time"
|
>
|
{{ rec.operatorName }} — {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
|
</el-timeline-item>
|
</el-timeline>
|
<el-empty v-else description="暂无审批记录" :image-size="60" />
|
<template #footer>
|
<el-button type="primary" @click="detailDialog.visible = false">关 闭</el-button>
|
</template>
|
</el-dialog>
|
|
<!-- 审批 -->
|
<el-dialog
|
v-model="approveDialog.visible"
|
title="差旅报销审批"
|
width="1000px"
|
append-to-body
|
destroy-on-close
|
@closed="approveOpinion = ''"
|
>
|
<DetailPanel :row="approveDialog.row" />
|
<el-divider content-position="left">流程进度</el-divider>
|
<ApprovalFlowProgress
|
:nodes="approveDialog.row?.approvalFlowNodes"
|
:current-index="approveDialog.row?.currentNodeIndex ?? 0"
|
/>
|
<el-form label-width="100px" class="mt16">
|
<el-form-item label="审批意见">
|
<el-input
|
v-model="approveOpinion"
|
type="textarea"
|
:rows="3"
|
maxlength="500"
|
show-word-limit
|
placeholder="通过可留空;驳回请填写原因"
|
/>
|
</el-form-item>
|
</el-form>
|
<template #footer>
|
<el-button type="success" @click="submitApprove('approved')">通 过</el-button>
|
<el-button type="danger" @click="submitApprove('rejected')">驳 回</el-button>
|
<el-button @click="approveDialog.visible = false">取 消</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
|
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
|
import ApprovalFlowProgress from "./components/ApprovalFlowProgress.vue";
|
import DetailPanel from "./components/DetailPanel.vue";
|
import { useTravelReimburse } from "./useTravelReimburse.js";
|
|
const tr = useTravelReimburse();
|
const {
|
Search,
|
EXPENSE_SUBJECT_OPTIONS,
|
expenseSubjectLabel,
|
searchForm,
|
tableLoading,
|
page,
|
tableData,
|
tableColumn,
|
importInputRef,
|
formRef,
|
form,
|
formDialog,
|
formRules,
|
detailDialog,
|
detailRow,
|
approveDialog,
|
approveOpinion,
|
applicantFormSearchLoading,
|
applicantFormOptions,
|
flowUserOptions,
|
travelDaysDisplay,
|
travelTierLabel,
|
suggestedLivingSubsidy,
|
suggestedTransportSubsidy,
|
suggestedHotelLimit,
|
detailTotalAmount,
|
overBudgetWarnings,
|
budgetHint,
|
handleQuery,
|
resetSearch,
|
pagination,
|
remoteSearchApplicantForm,
|
userSelectLabel,
|
onApplicantChange,
|
recalcTravelStandards,
|
onTravelRangeChange,
|
onDetailAmountChange,
|
onApprovalFlowChange,
|
addExpenseDetail,
|
removeExpenseDetail,
|
syncApplyAmountFromDetails,
|
openFormDialog,
|
onFormClosed,
|
submitForm,
|
openDetail,
|
approvalActionLabel,
|
submitApprove,
|
handleExport,
|
handleImportClick,
|
onImportFile,
|
} = tr;
|
</script>
|
|
<style scoped>
|
.mb20 {
|
margin-bottom: 20px;
|
}
|
.mb16 {
|
margin-bottom: 16px;
|
}
|
.mb8 {
|
margin-bottom: 8px;
|
}
|
.mt16 {
|
margin-top: 16px;
|
}
|
.search_form {
|
display: flex;
|
flex-wrap: wrap;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
}
|
.search_actions {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 8px;
|
}
|
.search_title {
|
font-size: 14px;
|
color: var(--el-text-color-regular);
|
}
|
.sr-only-input {
|
position: absolute;
|
width: 1px;
|
height: 1px;
|
padding: 0;
|
margin: -1px;
|
overflow: hidden;
|
clip: rect(0, 0, 0, 0);
|
white-space: nowrap;
|
border: 0;
|
}
|
.form-section {
|
margin-bottom: 16px;
|
border: 1px solid var(--el-border-color-lighter);
|
}
|
.form-section :deep(.el-card__header) {
|
padding: 12px 16px;
|
background: var(--el-fill-color-lighter);
|
}
|
.form-section :deep(.el-card__body) {
|
padding: 16px 16px 4px;
|
}
|
.card-header-title {
|
font-size: 15px;
|
font-weight: 600;
|
}
|
.card-header-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
}
|
.amount-row {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
width: 100%;
|
}
|
.amount-input {
|
flex: 1;
|
min-width: 160px;
|
}
|
.w-full {
|
width: 100%;
|
}
|
.attachment-form-item {
|
margin-bottom: 0;
|
}
|
.detail-table {
|
margin-bottom: 0;
|
}
|
.section-title {
|
font-size: 15px;
|
font-weight: 600;
|
margin: 8px 0 12px;
|
color: var(--el-text-color-primary);
|
border-left: 3px solid var(--el-color-primary);
|
padding-left: 8px;
|
}
|
.field-tip {
|
font-size: 12px;
|
color: var(--el-text-color-secondary);
|
margin-top: 4px;
|
}
|
.warn-list {
|
margin: 0;
|
padding-left: 18px;
|
}
|
.detail-toolbar {
|
margin-bottom: 8px;
|
}
|
.upload-block {
|
width: 100%;
|
}
|
.flow-tip {
|
font-size: 12px;
|
color: var(--el-text-color-secondary);
|
margin-top: 8px;
|
}
|
.sync-btn {
|
margin-top: 4px;
|
}
|
.travel-reimburse-form-dialog :deep(.el-dialog__body) {
|
padding-top: 12px;
|
}
|
.travel-reimburse-form :deep(.el-form-item) {
|
margin-bottom: 18px;
|
}
|
.travel-reimburse-form :deep(.el-input-number) {
|
width: 100%;
|
}
|
.travel-reimburse-form :deep(.el-row) {
|
margin-bottom: 0;
|
}
|
</style>
|