yyb
21 小时以前 cdb0e306d0b83902908f20da0903bd9df901c81b
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -1,12 +1,623 @@
<!--
  模块中文名:差旅报销
  目录标识:ReimburseManage/travel-reimburse(travel-reimburse → 中文:差旅报销)
  复用页面:@/views/procurementManagement/procurementLedger/index.vue(采购台账;文件名 index.vue → 入口页)
-->
<!--OA模块:差旅报销-->
<template>
  <ProcurementLedger />
  <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 ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
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>