| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import request from "@/utils/request"; |
| | | |
| | | /** å页æ¥è¯¢è´¢å¡æ¥é GET /finReimbursement/listPage */ |
| | | export function listFinReimbursementPage(params) { |
| | | return request({ |
| | | url: "/finReimbursement/listPage", |
| | | method: "get", |
| | | params, |
| | | }); |
| | | } |
| | | |
| | | /** 详æ
queryï¼Spring ç»å® finReimbursementDto.idï¼å¿ç¨ finReimbursementDto[id] */ |
| | | function buildFinReimbursementDetailParams(idOrDto) { |
| | | const raw = |
| | | typeof idOrDto === "object" && idOrDto !== null |
| | | ? idOrDto.id ?? idOrDto.reimbursementId |
| | | : idOrDto; |
| | | return { |
| | | "finReimbursementDto.id": raw, |
| | | id: raw, |
| | | }; |
| | | } |
| | | |
| | | /** æ¥è¯¢è´¢å¡æ¥é详æ
GET /finReimbursement/detail */ |
| | | export function getFinReimbursementDetail(idOrDto) { |
| | | return request({ |
| | | url: "/finReimbursement/detail", |
| | | method: "get", |
| | | params: buildFinReimbursementDetailParams(idOrDto), |
| | | }); |
| | | } |
| | | |
| | | /** æ°å¢è´¢å¡æ¥é POST /finReimbursement/save */ |
| | | export function saveFinReimbursement(finReimbursementDto) { |
| | | return request({ |
| | | url: "/finReimbursement/save", |
| | | method: "post", |
| | | data: finReimbursementDto, |
| | | }); |
| | | } |
| | | |
| | | /** ä¿®æ¹è´¢å¡æ¥é POST /finReimbursement/update */ |
| | | export function updateFinReimbursement(finReimbursementDto) { |
| | | return request({ |
| | | url: "/finReimbursement/update", |
| | | method: "post", |
| | | data: finReimbursementDto, |
| | | }); |
| | | } |
| | | |
| | | /** å é¤è´¢å¡æ¥é DELETE /finReimbursement/deleteï¼body 为 ID æ°ç»ï¼ */ |
| | | export function deleteFinReimbursement(ids) { |
| | | const idList = (Array.isArray(ids) ? ids : [ids]).filter( |
| | | (id) => id != null && id !== "" |
| | | ); |
| | | return request({ |
| | | url: "/finReimbursement/delete", |
| | | method: "delete", |
| | | data: idList, |
| | | }); |
| | | } |
| | | |
| | | /** æ°å¢èµ° saveï¼ä¿®æ¹èµ° updateï¼ä¸æ¥å£ææ¡£ä¸è´ï¼ */ |
| | | export function persistFinReimbursement(finReimbursementDto, isEdit = false) { |
| | | if (isEdit) { |
| | | return updateFinReimbursement(finReimbursementDto); |
| | | } |
| | | const payload = { ...finReimbursementDto }; |
| | | delete payload.id; |
| | | return saveFinReimbursement(payload); |
| | | } |
| | |
| | | /** æ¥é管ç */ |
| | | travelReimburse: `/${P}/ReimburseManage/travel-reimburse/index`, |
| | | costReimburse: `/${P}/ReimburseManage/cost-reimburse/index`, |
| | | reimburseDetail: `/${P}/ReimburseManage/reimburse-detail/index`, |
| | | reimburseForm: `/${P}/ReimburseManage/reimburse-form/index`, |
| | | /** åå管ç */ |
| | | purchaseContract: `/${P}/ContractManage/purchase-contract/index`, |
| | | saleContract: `/${P}/ContractManage/sale-contract/index`, |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ReimburseManage/reimburse-detail/index", |
| | | "style": { |
| | | "navigationBarTitleText": "æ¥é详æ
", |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ReimburseManage/reimburse-form/index", |
| | | "style": { |
| | | "navigationBarTitleText": "æ¥éå¡«æ¥", |
| | | "navigationStyle": "custom" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/oa/ContractManage/purchase-contract/index", |
| | | "style": { |
| | | "navigationBarTitleText": "éè´åå", |
| | |
| | | <!-- |
| | | OA / 审æ¹ç®¡ç / 审æ¹å¤ç |
| | | è·¯ç±ï¼/pages/oa/ApproveManage/approve-list/approve |
| | | å·®æ
/è´¹ç¨æ¥éä½¿ç¨æ¥é详æ
+ 审æ¹å表 approve æ¥å£ |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page"> |
| | | <PageHeader title="审æ¹å¤ç" |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <scroll-view v-if="row" |
| | | <scroll-view v-if="displayReady" |
| | | class="oa-detail-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <ApproveInstanceDetailBody :row="row" |
| | | <ReimburseInstanceDetailBody v-if="isReimburse" |
| | | :reimburse-row="reimburseRow" |
| | | :module-key="detailModuleKey" /> |
| | | |
| | | <ApproveInstanceDetailBody v-else |
| | | :row="row" |
| | | :module-key="detailModuleKey" /> |
| | | |
| | | <view class="section-card opinion-card"> |
| | |
| | | <view v-else |
| | | class="oa-empty"> |
| | | <up-empty mode="data" |
| | | text="æªè·åå°å®¡æ¹æ°æ®" /> |
| | | :text="loading ? 'å è½½ä¸' : 'æªè·åå°å®¡æ¹æ°æ®'" /> |
| | | </view> |
| | | |
| | | <view v-if="row" |
| | | <view v-if="displayReady" |
| | | class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | :class="{ 'is-disabled': submitting }" |
| | |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue"; |
| | | import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue"; |
| | | import { approveApprovalInstance } from "@/api/oa/approvalInstance.js"; |
| | | import { |
| | | buildApproveInstanceDto, |
| | |
| | | loadInstanceRow, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js"; |
| | | import { |
| | | inferReimburseModuleKeyFromInstance, |
| | | isReimburseApprovalInstance, |
| | | loadReimburseDetailForInstance, |
| | | } from "../../_utils/reimburseApproveBridge.js"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | |
| | | const instanceId = ref(""); |
| | | const row = ref(null); |
| | | const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value)); |
| | | const reimburseRow = ref(null); |
| | | const loading = ref(false); |
| | | const approveOpinion = ref(""); |
| | | const submitting = ref(false); |
| | | |
| | | const goBack = () => { |
| | | uni.navigateBack(); |
| | | }; |
| | | const isReimburse = computed(() => isReimburseApprovalInstance(row.value)); |
| | | |
| | | const detailModuleKey = computed(() => { |
| | | if (isReimburse.value) { |
| | | return ( |
| | | reimburseRow.value?.moduleKey || |
| | | inferReimburseModuleKeyFromInstance(row.value) |
| | | ); |
| | | } |
| | | return inferModuleKeyFromRow(row.value); |
| | | }); |
| | | |
| | | const pageTitle = computed(() => { |
| | | if (isReimburse.value) { |
| | | const label = getApprovalModuleConfig(detailModuleKey.value)?.label || "æ¥é"; |
| | | return `${label}审æ¹`; |
| | | } |
| | | return "审æ¹å¤ç"; |
| | | }); |
| | | |
| | | const displayReady = computed(() => |
| | | isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value) |
| | | ); |
| | | |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const submitApprove = uiResult => { |
| | | if (!row.value?.id || submitting.value) return; |
| | |
| | | const prevRoute = pages[pages.length - 2]?.route || ""; |
| | | const delta = prevRoute.includes("approve-list/detail") ? 2 : 1; |
| | | uni.navigateBack({ delta }); |
| | | }, 300); |
| | | }, 400); |
| | | }) |
| | | .catch(() => { |
| | | uni.showToast({ title: "å®¡æ¹æä½å¤±è´¥", icon: "none" }); |
| | |
| | | }); |
| | | }; |
| | | |
| | | onLoad(options => { |
| | | onLoad(async options => { |
| | | if (!options?.id) { |
| | | uni.showToast({ title: "缺å°å®¡æ¹ ID", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | instanceId.value = options.id; |
| | | const cached = loadInstanceRow(options.id); |
| | | if (!cached) { |
| | | uni.showToast({ title: "请ä»å表è¿å
¥å®¡æ¹", icon: "none" }); |
| | | uni.showToast({ title: "请ä»å表è¿å
¥", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | if (!canApproveInstance(cached)) { |
| | | uni.showToast({ title: "å½å审æ¹ä¸å¯å¤ç", icon: "none" }); |
| | | uni.showToast({ title: "å½åå®¡æ¹æ 鿍å¤ç", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | row.value = cached; |
| | | if (isReimburseApprovalInstance(cached)) { |
| | | loading.value = true; |
| | | try { |
| | | const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached); |
| | | reimburseRow.value = mapped; |
| | | } catch { |
| | | uni.showToast({ title: "å è½½æ¥é详æ
失败", icon: "none" }); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | |
| | | $primary: #2979ff; |
| | | |
| | | .opinion-card { |
| | | margin-top: 10px; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05); |
| | | } |
| | | |
| | | .section-head { |
| | | padding: 12px 16px; |
| | | border-bottom: 1px solid #f2f4f7; |
| | | } |
| | | |
| | | .section-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #1f2d3d; |
| | | padding-left: 10px; |
| | | border-left: 3px solid $primary; |
| | | line-height: 1.2; |
| | | } |
| | | |
| | | .opinion-wrap { |
| | | padding: 12px 16px 16px; |
| | | } |
| | | </style> |
| | |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page"> |
| | | <PageHeader title="审æ¹è¯¦æ
" |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <scroll-view v-if="row" |
| | | <scroll-view v-if="displayReady" |
| | | class="oa-detail-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <ApproveInstanceDetailBody :row="row" |
| | | <ReimburseInstanceDetailBody v-if="isReimburse" |
| | | :reimburse-row="reimburseRow" |
| | | :module-key="detailModuleKey" /> |
| | | <ApproveInstanceDetailBody v-else |
| | | :row="row" |
| | | :module-key="detailModuleKey" /> |
| | | </scroll-view> |
| | | |
| | | <view v-else |
| | | class="oa-empty"> |
| | | <up-empty mode="data" |
| | | text="æªè·åå°å®¡æ¹æ°æ®" /> |
| | | :text="loading ? 'å è½½ä¸' : 'æªè·åå°å®¡æ¹æ°æ®'" /> |
| | | </view> |
| | | |
| | | <view v-if="row" |
| | | <view v-if="displayReady" |
| | | class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | @click="goBack">è¿å</text> |
| | |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue"; |
| | | import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { |
| | | canApproveInstance, |
| | | canEditBusinessInstanceRow, |
| | | canModifyInstance, |
| | | EDIT_STORAGE_KEY, |
| | | loadInstanceRow, |
| | | stashInstanceRow, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js"; |
| | | import { |
| | | inferReimburseModuleKeyFromInstance, |
| | | isReimburseApprovalInstance, |
| | | loadReimburseDetailForInstance, |
| | | resolveFinReimbursementIdFromInstance, |
| | | stashReimburseEditFromApprove, |
| | | } from "../../_utils/reimburseApproveBridge.js"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { canEditReimbursementRow } from "../../_utils/finReimbursementMappers.js"; |
| | | |
| | | const userStore = useUserStore(); |
| | | const instanceId = ref(""); |
| | | const fromBusiness = ref(false); |
| | | const row = ref(null); |
| | | const reimburseRow = ref(null); |
| | | const loading = ref(false); |
| | | |
| | | const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value)); |
| | | const detailModuleKey = computed(() => { |
| | | if (isReimburse.value) { |
| | | return ( |
| | | reimburseRow.value?.moduleKey || |
| | | inferReimburseModuleKeyFromInstance(row.value) |
| | | ); |
| | | } |
| | | return inferModuleKeyFromRow(row.value); |
| | | }); |
| | | |
| | | const isReimburse = computed(() => isReimburseApprovalInstance(row.value)); |
| | | |
| | | const pageTitle = computed(() => { |
| | | if (isReimburse.value) { |
| | | return getApprovalModuleConfig(detailModuleKey.value)?.label |
| | | ? `${getApprovalModuleConfig(detailModuleKey.value).label}详æ
` |
| | | : "æ¥é详æ
"; |
| | | } |
| | | return "审æ¹è¯¦æ
"; |
| | | }); |
| | | |
| | | const displayReady = computed(() => |
| | | isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value) |
| | | ); |
| | | |
| | | const showEdit = computed(() => { |
| | | if (isReimburse.value) { |
| | | return canEditReimbursementRow(reimburseRow.value); |
| | | } |
| | | if (fromBusiness.value) { |
| | | return canEditBusinessInstanceRow(row.value); |
| | | } |
| | | return canModifyInstance(row.value, userStore); |
| | | }); |
| | | |
| | | const showApprove = computed(() => canApproveInstance(row.value)); |
| | | |
| | | const goBack = () => { |
| | | uni.navigateBack(); |
| | | }; |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const goEdit = () => { |
| | | if (!showEdit.value || !row.value?.id) return; |
| | | if (fromBusiness.value && !canEditBusinessInstanceRow(row.value)) { |
| | | uni.showToast({ title: "è¿è¡ä¸æå·²å®æç审æ¹ä¸å¯ä¿®æ¹", icon: "none" }); |
| | | if (!showEdit.value) return; |
| | | if (isReimburse.value) { |
| | | const mk = detailModuleKey.value; |
| | | const rid = resolveFinReimbursementIdFromInstance(row.value); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | stashReimburseEditFromApprove(mk, rid); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | return; |
| | | } |
| | | uni.setStorageSync(EDIT_STORAGE_KEY, row.value); |
| | | if (!row.value?.id) return; |
| | | const mk = detailModuleKey.value; |
| | | const q = mk ? `&moduleKey=${mk}` : ""; |
| | | uni.navigateTo({ |
| | |
| | | }); |
| | | }; |
| | | |
| | | onLoad(options => { |
| | | onLoad(async options => { |
| | | fromBusiness.value = options?.from === "business"; |
| | | if (!options?.id) { |
| | | uni.showToast({ title: "缺å°å®¡æ¹ ID", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | instanceId.value = options.id; |
| | | const cached = loadInstanceRow(options.id); |
| | | if (!cached) { |
| | | uni.showToast({ title: "请ä»å表è¿å
¥è¯¦æ
", icon: "none" }); |
| | |
| | | return; |
| | | } |
| | | row.value = cached; |
| | | if (isReimburseApprovalInstance(cached)) { |
| | | loading.value = true; |
| | | try { |
| | | const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached); |
| | | reimburseRow.value = mapped; |
| | | } catch { |
| | | uni.showToast({ title: "å è½½æ¥é详æ
失败", icon: "none" }); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | |
| | | businessStatusClass, |
| | | businessStatusText, |
| | | canModifyInstance, |
| | | EDIT_STORAGE_KEY, |
| | | stashInstanceRow, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import { |
| | | inferReimburseModuleKeyFromInstance, |
| | | resolveFinReimbursementIdFromInstance, |
| | | stashReimburseEditFromApprove, |
| | | } from "../../_utils/reimburseApproveBridge.js"; |
| | | |
| | | const userStore = useUserStore(); |
| | | const queryParams = reactive({ keyword: "" }); |
| | |
| | | uni.showToast({ title: "ä»
è¿è¡ä¸çæ¬äººç³è¯·å¯ç¼è¾", icon: "none" }); |
| | | return; |
| | | } |
| | | const mk = inferReimburseModuleKeyFromInstance(item); |
| | | if (mk) { |
| | | const rid = resolveFinReimbursementIdFromInstance(item); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | stashReimburseEditFromApprove(mk, rid); |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | return; |
| | | } |
| | | if (!item?.id) return; |
| | | uni.setStorageSync(EDIT_STORAGE_KEY, item); |
| | | stashInstanceRow(item); |
| | | uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}` }); |
| | | }; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | æ¥éå®¡æ¹æµç¨ï¼å¯æç´¢é人ï¼ç¹éå³ç¡®è®¤ï¼ |
| | | --> |
| | | <template> |
| | | <view class="flow-wrap"> |
| | | <view v-for="(item, index) in innerList" |
| | | :key="item._uid" |
| | | class="flow-node-block"> |
| | | <view class="flow-node-card"> |
| | | <view class="node-header"> |
| | | <view class="node-level-badge">{{ index + 1 }}</view> |
| | | <text class="node-level-text">第{{ levelLabel(index + 1) }}级审æ¹</text> |
| | | <view v-if="innerList.length > 1" |
| | | class="node-delete" |
| | | @click="remove(index)"> |
| | | <up-icon name="trash" |
| | | size="16" |
| | | color="#f56c6c" /> |
| | | </view> |
| | | </view> |
| | | <view class="approver-row" |
| | | @click="openPicker(index)"> |
| | | <view class="approver-avatar" |
| | | :style="{ backgroundColor: avatarColor(item.approverName) }"> |
| | | {{ (item.approverName || '+').charAt(0) }} |
| | | </view> |
| | | <view class="approver-meta"> |
| | | <text class="approver-name">{{ item.approverName || 'ç¹å»éæ©å®¡æ¹äºº' }}</text> |
| | | <text class="approver-hint">æ¯ææç´¢å§åæå·¥å·</text> |
| | | </view> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view v-if="index < innerList.length - 1" |
| | | class="flow-connector"> |
| | | <view class="flow-connector-line" /> |
| | | </view> |
| | | </view> |
| | | <view class="add-node-bar" |
| | | @click="addNode"> |
| | | <up-icon name="plus-circle" |
| | | size="18" |
| | | color="#2979ff" /> |
| | | <text>æ·»å 审æ¹çº§æ¬¡</text> |
| | | </view> |
| | | |
| | | <OaUserSearchPicker v-model:show="pickerShow" |
| | | v-model="pickerUserId" |
| | | title="鿩审æ¹äºº" |
| | | :users="userOptions" |
| | | :show-self-quick="false" |
| | | @select="onUserSelected" /> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, watch } from "vue"; |
| | | import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue"; |
| | | import { userAvatarColor } from "../../_utils/userPickerUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { type: Array, default: () => [] }, |
| | | userOptions: { type: Array, default: () => [] }, |
| | | }); |
| | | const emit = defineEmits(["update:modelValue"]); |
| | | |
| | | const innerList = ref([]); |
| | | const pickerShow = ref(false); |
| | | const pickerUserId = ref(""); |
| | | const editingIndex = ref(-1); |
| | | |
| | | function newUid() { |
| | | return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; |
| | | } |
| | | |
| | | function levelLabel(n) { |
| | | const t = ["ä¸", "äº", "ä¸", "å", "äº", "å
", "ä¸", "å
«"]; |
| | | return t[n - 1] || String(n); |
| | | } |
| | | |
| | | function avatarColor(name) { |
| | | return userAvatarColor(name); |
| | | } |
| | | |
| | | function mapIn(rows) { |
| | | return (rows || []).map((n, i) => ({ |
| | | _uid: n._uid || newUid(), |
| | | nodeOrder: n.nodeOrder ?? i + 1, |
| | | signMode: n.signMode || "countersign", |
| | | approverId: n.approverId ?? "", |
| | | approverName: n.approverName || "", |
| | | id: n.id, |
| | | templateId: n.templateId, |
| | | })); |
| | | } |
| | | |
| | | function mapOut() { |
| | | return innerList.value.map((n, i) => ({ |
| | | nodeOrder: i + 1, |
| | | signMode: n.signMode || "countersign", |
| | | approverId: n.approverId, |
| | | approverName: n.approverName, |
| | | id: n.id, |
| | | templateId: n.templateId, |
| | | })); |
| | | } |
| | | |
| | | function syncEmit() { |
| | | emit("update:modelValue", mapOut()); |
| | | } |
| | | |
| | | watch( |
| | | () => props.modelValue, |
| | | v => { |
| | | innerList.value = mapIn(v); |
| | | if (!innerList.value.length) { |
| | | innerList.value = [ |
| | | { _uid: newUid(), nodeOrder: 1, signMode: "countersign", approverId: "", approverName: "" }, |
| | | ]; |
| | | } |
| | | }, |
| | | { immediate: true, deep: true } |
| | | ); |
| | | |
| | | function addNode() { |
| | | innerList.value.push({ |
| | | _uid: newUid(), |
| | | nodeOrder: innerList.value.length + 1, |
| | | signMode: "countersign", |
| | | approverId: "", |
| | | approverName: "", |
| | | }); |
| | | syncEmit(); |
| | | } |
| | | |
| | | function remove(index) { |
| | | if (innerList.value.length <= 1) { |
| | | uni.showToast({ title: "è³å°ä¿çä¸ä¸ªå®¡æ¹èç¹", icon: "none" }); |
| | | return; |
| | | } |
| | | innerList.value.splice(index, 1); |
| | | syncEmit(); |
| | | } |
| | | |
| | | function openPicker(index) { |
| | | editingIndex.value = index; |
| | | pickerUserId.value = innerList.value[index]?.approverId || ""; |
| | | pickerShow.value = true; |
| | | } |
| | | |
| | | function onUserSelected(u) { |
| | | const node = innerList.value[editingIndex.value]; |
| | | if (!node) return; |
| | | node.approverId = u.userId ?? u.id; |
| | | node.approverName = u.nickName || u.userName || ""; |
| | | syncEmit(); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .flow-node-card { |
| | | background: #f8f9fb; |
| | | border-radius: 10px; |
| | | padding: 12px; |
| | | border: 1px solid #eef0f3; |
| | | } |
| | | .node-header { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 10px; |
| | | } |
| | | .node-level-badge { |
| | | width: 22px; |
| | | height: 22px; |
| | | border-radius: 50%; |
| | | background: #2979ff; |
| | | color: #fff; |
| | | font-size: 12px; |
| | | text-align: center; |
| | | line-height: 22px; |
| | | margin-right: 8px; |
| | | } |
| | | .node-level-text { |
| | | flex: 1; |
| | | font-size: 14px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | } |
| | | .approver-row { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 10px 12px; |
| | | background: #fff; |
| | | border-radius: 8px; |
| | | } |
| | | .approver-avatar { |
| | | width: 36px; |
| | | height: 36px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | .approver-meta { |
| | | flex: 1; |
| | | margin-left: 10px; |
| | | min-width: 0; |
| | | } |
| | | .approver-name { |
| | | display: block; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | } |
| | | .approver-hint { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | margin-top: 2px; |
| | | } |
| | | .flow-connector { |
| | | display: flex; |
| | | justify-content: center; |
| | | padding: 6px 0; |
| | | } |
| | | .flow-connector-line { |
| | | width: 2px; |
| | | height: 14px; |
| | | background: #dcdfe6; |
| | | } |
| | | .add-node-bar { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 6px; |
| | | padding: 14px 0 4px; |
| | | color: #2979ff; |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | æ¥éæç»åæ¡ç¼è¾ï¼åºé¨å¼¹å±ï¼ |
| | | --> |
| | | <template> |
| | | <up-popup :show="show" |
| | | mode="bottom" |
| | | round="16" |
| | | :safe-area-inset-bottom="true" |
| | | @close="close"> |
| | | <view class="detail-sheet"> |
| | | <view class="sheet-handle" /> |
| | | <view class="sheet-head"> |
| | | <text class="sheet-cancel" |
| | | @click="close">åæ¶</text> |
| | | <text class="sheet-title">{{ title }}</text> |
| | | <text class="sheet-confirm" |
| | | @click="confirm">ä¿å</text> |
| | | </view> |
| | | |
| | | <scroll-view scroll-y |
| | | class="sheet-body" |
| | | :show-scrollbar="false"> |
| | | <view class="sheet-group"> |
| | | <view class="sheet-cell sheet-cell--tap" |
| | | @click="showDatePicker = true"> |
| | | <text class="sheet-label required">åç¥¨æ¥æ</text> |
| | | <view class="sheet-value-wrap"> |
| | | <text class="sheet-value" |
| | | :class="{ placeholder: !draft.invoiceDate }"> |
| | | {{ draft.invoiceDate || 'è¯·éæ©' }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="sheet-cell sheet-cell--tap" |
| | | @click="showSubjectSheet = true"> |
| | | <text class="sheet-label required">è´¹ç¨ç§ç®</text> |
| | | <view class="sheet-value-wrap"> |
| | | <text class="sheet-value" |
| | | :class="{ placeholder: !draft.expenseSubject }"> |
| | | {{ subjectText }} |
| | | </text> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="sheet-cell"> |
| | | <text class="sheet-label required">éé¢</text> |
| | | <view class="sheet-input-wrap"> |
| | | <up-input v-model="draft.amount" |
| | | type="digit" |
| | | placeholder="0.00" |
| | | border="none" |
| | | input-align="right" /> |
| | | <text class="sheet-unit">å
</text> |
| | | </view> |
| | | </view> |
| | | <view class="sheet-cell sheet-cell--col"> |
| | | <text class="sheet-label">æè¿°</text> |
| | | <view class="sheet-textarea-wrap"> |
| | | <up-textarea v-model="draft.description" |
| | | placeholder="è´¹ç¨è¯´æï¼éå¡«ï¼" |
| | | maxlength="200" |
| | | border="none" |
| | | height="64" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="showDelete" |
| | | class="sheet-delete" |
| | | @click="emit('delete')"> |
| | | å 餿¬æ¡æç» |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | | |
| | | <up-action-sheet :show="showSubjectSheet" |
| | | title="è´¹ç¨ç§ç®" |
| | | :actions="subjectActions" |
| | | @select="onSubjectSelect" |
| | | @close="showSubjectSheet = false" /> |
| | | |
| | | <up-popup :show="showDatePicker" |
| | | mode="bottom" |
| | | round="16" |
| | | @close="showDatePicker = false"> |
| | | <up-datetime-picker :show="true" |
| | | v-model="datePickerTs" |
| | | mode="date" |
| | | @confirm="onDateConfirm" |
| | | @cancel="showDatePicker = false" /> |
| | | </up-popup> |
| | | </up-popup> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref, watch } from "vue"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { expenseSubjectLabel as costSubjectLabel } from "../_utils/costReimburseUtils.js"; |
| | | import { expenseSubjectLabel as travelSubjectLabel } from "../_utils/travelReimburseUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | show: { type: Boolean, default: false }, |
| | | modelValue: { type: Object, default: () => ({}) }, |
| | | index: { type: Number, default: 0 }, |
| | | isTravel: { type: Boolean, default: true }, |
| | | subjectOptions: { type: Array, default: () => [] }, |
| | | showDelete: { type: Boolean, default: true }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:show", "update:modelValue", "confirm", "delete"]); |
| | | |
| | | const draft = reactive({ |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }); |
| | | |
| | | const showDatePicker = ref(false); |
| | | const showSubjectSheet = ref(false); |
| | | const datePickerTs = ref(Date.now()); |
| | | |
| | | const title = computed(() => `æç» ${props.index + 1}`); |
| | | |
| | | const subjectActions = computed(() => |
| | | (props.subjectOptions || []).map(x => ({ name: x.label, value: x.value })) |
| | | ); |
| | | |
| | | const subjectText = computed(() => resolveSubjectLabel(draft.expenseSubject)); |
| | | |
| | | function resolveSubjectLabel(v) { |
| | | if (!v) return "è¯·éæ©"; |
| | | const labelFn = props.isTravel ? travelSubjectLabel : costSubjectLabel; |
| | | const t = labelFn(v); |
| | | if (t && t !== "â") return t; |
| | | const hit = (props.subjectOptions || []).find(x => x.value === v || x.label === v); |
| | | return hit?.label || v; |
| | | } |
| | | |
| | | watch( |
| | | () => props.show, |
| | | v => { |
| | | if (v && props.modelValue) { |
| | | Object.assign(draft, { |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | ...JSON.parse(JSON.stringify(props.modelValue)), |
| | | }); |
| | | } |
| | | } |
| | | ); |
| | | |
| | | function close() { |
| | | emit("update:show", false); |
| | | } |
| | | |
| | | function confirm() { |
| | | if (!draft.invoiceDate) { |
| | | uni.showToast({ title: "è¯·éæ©åç¥¨æ¥æ", icon: "none" }); |
| | | return; |
| | | } |
| | | if (!draft.expenseSubject) { |
| | | uni.showToast({ title: "è¯·éæ©è´¹ç¨ç§ç®", icon: "none" }); |
| | | return; |
| | | } |
| | | if (draft.amount === "" || draft.amount == null) { |
| | | uni.showToast({ title: "请填åéé¢", icon: "none" }); |
| | | return; |
| | | } |
| | | emit("update:modelValue", { ...draft }); |
| | | emit("confirm", { ...draft }); |
| | | emit("update:show", false); |
| | | } |
| | | |
| | | function onSubjectSelect(action) { |
| | | draft.expenseSubject = action.value; |
| | | showSubjectSheet.value = false; |
| | | } |
| | | |
| | | function onDateConfirm(e) { |
| | | const ts = e?.value ?? datePickerTs.value; |
| | | draft.invoiceDate = parseTime(ts, "{y}-{m}-{d}"); |
| | | showDatePicker.value = false; |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .detail-sheet { |
| | | background: #fff; |
| | | border-radius: 16px 16px 0 0; |
| | | max-height: 85vh; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | .sheet-handle { |
| | | width: 36px; |
| | | height: 4px; |
| | | background: #e4e7ed; |
| | | border-radius: 2px; |
| | | margin: 8px auto 4px; |
| | | } |
| | | .sheet-head { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 8px 16px 12px; |
| | | border-bottom: 1px solid #f0f2f5; |
| | | } |
| | | .sheet-cancel { |
| | | font-size: 15px; |
| | | color: #909399; |
| | | min-width: 48px; |
| | | } |
| | | .sheet-title { |
| | | flex: 1; |
| | | text-align: center; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | .sheet-confirm { |
| | | font-size: 15px; |
| | | color: #2979ff; |
| | | font-weight: 600; |
| | | min-width: 48px; |
| | | text-align: right; |
| | | } |
| | | .sheet-body { |
| | | max-height: 70vh; |
| | | padding-bottom: env(safe-area-inset-bottom); |
| | | } |
| | | .sheet-group { |
| | | margin: 12px 16px; |
| | | background: #f8f9fb; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | } |
| | | .sheet-cell { |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 52px; |
| | | padding: 12px 14px; |
| | | background: #fff; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | &--col { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | &--tap:active { |
| | | background: #fafbfc; |
| | | } |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | .sheet-label { |
| | | width: 80px; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | flex-shrink: 0; |
| | | &.required::before { |
| | | content: "*"; |
| | | color: #f56c6c; |
| | | margin-right: 2px; |
| | | } |
| | | } |
| | | .sheet-value-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | gap: 4px; |
| | | } |
| | | .sheet-value { |
| | | font-size: 15px; |
| | | color: #303133; |
| | | &.placeholder { |
| | | color: #c0c4cc; |
| | | } |
| | | } |
| | | .sheet-input-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | } |
| | | .sheet-unit { |
| | | font-size: 14px; |
| | | color: #909399; |
| | | margin-left: 4px; |
| | | } |
| | | .sheet-textarea-wrap { |
| | | width: 100%; |
| | | margin-top: 8px; |
| | | background: #f5f7fa; |
| | | border-radius: 8px; |
| | | padding: 4px 8px; |
| | | } |
| | | .sheet-delete { |
| | | margin: 16px; |
| | | text-align: center; |
| | | font-size: 15px; |
| | | color: #f56c6c; |
| | | padding: 14px; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | border: 1px solid #fde2e2; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥é详æ
å±ç¤ºï¼å表详æ
/ 审æ¹è¯¦æ
å
±ç¨ï¼ |
| | | --> |
| | | <template> |
| | | <view class="rd-body"> |
| | | <!-- æ¦è¦ --> |
| | | <view class="rd-hero"> |
| | | <view class="rd-hero-top"> |
| | | <text class="rd-bill-no">{{ billNo }}</text> |
| | | <text :class="['rd-status', statusCssClass]">{{ statusText }}</text> |
| | | </view> |
| | | <text class="rd-reason">{{ reasonText }}</text> |
| | | <view class="rd-amount-row"> |
| | | <text class="rd-amount-label">ç³è¯·éé¢</text> |
| | | <text class="rd-amount">{{ amountText }}</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- ç³è¯·äºº --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">ç³è¯·äºº</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">å§å</text> |
| | | <text class="rd-value">{{ r.applicantName || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åå·¥ç¼å·</text> |
| | | <text class="rd-value">{{ r.applicantCode || r.applicantNo || "â" }}</text> |
| | | </view> |
| | | <view v-if="r.applicantDeptName || r.deptName" |
| | | class="rd-cell"> |
| | | <text class="rd-label">é¨é¨</text> |
| | | <text class="rd-value">{{ r.applicantDeptName || r.deptName }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- åºå·® / è´¹ç¨ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">{{ isTravel ? "åºå·®ä¿¡æ¯" : "è´¹ç¨ä¿¡æ¯" }}</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <template v-if="isTravel"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®å¼å§</text> |
| | | <text class="rd-value">{{ formatTime(r.travelStartTime) }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®ç»æ</text> |
| | | <text class="rd-value">{{ formatTime(r.travelEndTime) }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®å¤©æ°</text> |
| | | <text class="rd-value">{{ travelDaysText }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åºå·®å°</text> |
| | | <text class="rd-value">{{ r.departurePlace || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">ç®çå°</text> |
| | | <text class="rd-value">{{ r.destination || "â" }}</text> |
| | | </view> |
| | | </template> |
| | | <view v-else |
| | | class="rd-cell"> |
| | | <text class="rd-label">è´¹ç¨ç±»å</text> |
| | | <text class="rd-value">{{ expenseTypeText }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å·®æ
æ å --> |
| | | <view v-if="isTravel && hasTravelStandard" |
| | | class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">å·®æ
æ å</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view v-if="r.hotelStandard != null" |
| | | class="rd-cell"> |
| | | <text class="rd-label">é
åºæ å</text> |
| | | <text class="rd-value">{{ r.hotelStandard }} å
/æ</text> |
| | | </view> |
| | | <view v-if="r.hotelDays != null" |
| | | class="rd-cell"> |
| | | <text class="rd-label">ä½å®¿å¤©æ°</text> |
| | | <text class="rd-value">{{ r.hotelDays }} 天</text> |
| | | </view> |
| | | <view v-if="r.livingSubsidy != null" |
| | | class="rd-cell"> |
| | | <text class="rd-label">çæ´»è¡¥è´´</text> |
| | | <text class="rd-value">{{ r.livingSubsidy }} å
</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">æ åæ è®°</text> |
| | | <text class="rd-value">{{ r.standardTag || (r.needSpecialApproval ? "è¶
æ¯éç¹æ¹" : "卿 åå
") }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- æ¶æ¬¾ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">æ¶æ¬¾ä¿¡æ¯</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">æ¶æ¬¾äºº</text> |
| | | <text class="rd-value">{{ r.payeeName || r.payee || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">æ¶æ¬¾è´¦å·</text> |
| | | <text class="rd-value">{{ r.payeeAccount || "â" }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">弿·æ¯è¡</text> |
| | | <text class="rd-value">{{ r.payeeBank || r.bankBranch || "â" }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- æ¥éæç» --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">æ¥éæç»</text> |
| | | <text class="rd-section-count">å
± {{ detailRows.length }} æ¡</text> |
| | | </view> |
| | | <view v-if="detailRows.length" |
| | | class="rd-group"> |
| | | <view v-for="(d, idx) in detailRows" |
| | | :key="'d-' + idx" |
| | | class="rd-detail-item"> |
| | | <view class="rd-detail-head"> |
| | | <text class="rd-detail-badge">{{ idx + 1 }}</text> |
| | | <text class="rd-detail-title">{{ detailSubject(d) }}</text> |
| | | <text class="rd-detail-amount">{{ detailAmount(d) }}</text> |
| | | </view> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">åç¥¨æ¥æ</text> |
| | | <text class="rd-value">{{ d.invoiceDate || "â" }}</text> |
| | | </view> |
| | | <view v-if="d.description" |
| | | class="rd-cell"> |
| | | <text class="rd-label">æè¿°</text> |
| | | <text class="rd-value">{{ d.description }}</text> |
| | | </view> |
| | | <view v-if="d.invoiceNo" |
| | | class="rd-cell"> |
| | | <text class="rd-label">å票å·</text> |
| | | <text class="rd-value">{{ d.invoiceNo }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rd-group"> |
| | | <view class="rd-empty">ææ æ¥éæç»</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- éä»¶ --> |
| | | <view v-if="attachmentList.length" |
| | | class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">å票éä»¶</text> |
| | | </view> |
| | | <view class="rd-group"> |
| | | <view v-for="(f, i) in attachmentList" |
| | | :key="i" |
| | | class="rd-attach" |
| | | @click="openAttachment(f)"> |
| | | {{ f.name || "éä»¶" }} |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å®¡æ¹æµç¨ï¼tasksï¼ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">å®¡æ¹æµç¨</text> |
| | | <text class="rd-section-count">{{ flowNodesList.length }} 级</text> |
| | | </view> |
| | | <view v-if="flowNodesList.length" |
| | | class="rd-group"> |
| | | <view v-for="(node, nodeIndex) in flowNodesList" |
| | | :key="nodeIndex" |
| | | class="rd-flow-node"> |
| | | <view class="rd-flow-line"> |
| | | <view class="rd-flow-dot" /> |
| | | <view v-if="nodeIndex < flowNodesList.length - 1" |
| | | class="rd-flow-bar" /> |
| | | </view> |
| | | <view class="rd-flow-body"> |
| | | <text class="rd-flow-level">第{{ node.levelNo }}级 · {{ node.approveType === 'OR' ? 'æç¾' : 'ä¼ç¾' }}</text> |
| | | <view v-for="(a, ai) in node.approvers" |
| | | :key="ai" |
| | | class="rd-flow-approver"> |
| | | <view class="rd-flow-avatar" |
| | | :style="{ backgroundColor: avatarColor(a.approverName) }"> |
| | | {{ (a.approverName || "?").charAt(0) }} |
| | | </view> |
| | | <view class="rd-flow-approver-meta"> |
| | | <text class="rd-flow-name">{{ a.approverName || "â" }}</text> |
| | | <text v-if="a.taskStatus" |
| | | class="rd-flow-status">{{ taskStatusLabel(a.taskStatus) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rd-group"> |
| | | <view class="rd-empty">ææ å®¡æ¹èç¹</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 审æ¹è®°å½ï¼tasks ççï¼ --> |
| | | <view class="rd-section"> |
| | | <view class="rd-section-hd"> |
| | | <text class="rd-section-title">审æ¹è®°å½</text> |
| | | <text class="rd-section-count">{{ approvalRecords.length }} æ¡</text> |
| | | </view> |
| | | <view v-if="approvalRecords.length" |
| | | class="rd-group"> |
| | | <view v-for="(rec, index) in approvalRecords" |
| | | :key="rec.id ?? index" |
| | | class="rd-record-item"> |
| | | <view class="rd-record-head"> |
| | | <text class="rd-record-operator">{{ rec.operatorName }}</text> |
| | | <text class="rd-record-tag" |
| | | :class="'rd-record-tag--' + rec.result">{{ recordLabel(rec.result) }}</text> |
| | | </view> |
| | | <text v-if="rec.time" |
| | | class="rd-record-time">{{ rec.time }}</text> |
| | | <text class="rd-record-opinion">{{ rec.opinion || "æ æè§" }}</text> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rd-group"> |
| | | <view class="rd-empty">ææ å®¡æ¹è®°å½</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="rd-section"> |
| | | <view class="rd-group"> |
| | | <view class="rd-cell"> |
| | | <text class="rd-label">å建æ¶é´</text> |
| | | <text class="rd-value">{{ formatTime(r.createTime) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from "vue"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { isTravelReimbursementType } from "../../_utils/finReimbursementMappers.js"; |
| | | import { |
| | | billStatusCssClass, |
| | | billStatusLabel, |
| | | } from "../../_utils/finReimbursementMappers.js"; |
| | | import { expenseCategoryLabel, EXPENSE_SUBJECT_OPTIONS as COST_SUBJECTS } from "../_utils/costReimburseUtils.js"; |
| | | import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_SUBJECTS } from "../_utils/travelReimburseUtils.js"; |
| | | import { |
| | | resolveExpenseSubjectLabel, |
| | | formatDetailAmount, |
| | | } from "../_utils/expenseDetailDisplay.js"; |
| | | import { userAvatarColor } from "../../_utils/userPickerUtils.js"; |
| | | import { |
| | | mapTasksToFlowNodes, |
| | | recordActionLabel, |
| | | taskStatusText, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | import config from "@/config.js"; |
| | | |
| | | const props = defineProps({ |
| | | reimburseRow: { type: Object, default: () => ({}) }, |
| | | moduleKey: { type: String, default: "" }, |
| | | }); |
| | | |
| | | const r = computed(() => props.reimburseRow || {}); |
| | | |
| | | const isTravel = computed(() => |
| | | isTravelReimbursementType(r.value.reimbursementType ?? props.moduleKey) |
| | | ); |
| | | |
| | | const billNo = computed(() => r.value.billNo || r.value.reimburseNo || "â"); |
| | | const statusText = computed(() => |
| | | billStatusLabel(r.value.billStatus ?? r.value.status) |
| | | ); |
| | | const statusCssClass = computed(() => |
| | | billStatusCssClass(r.value) |
| | | ); |
| | | const reasonText = computed( |
| | | () => r.value.reason || r.value.reimburseReason || "â" |
| | | ); |
| | | const amountText = computed(() => |
| | | r.value.applyAmount != null ? String(r.value.applyAmount) : "â" |
| | | ); |
| | | |
| | | const expenseTypeText = computed(() => |
| | | expenseCategoryLabel(r.value.expenseCategory) || r.value.expenseType || "â" |
| | | ); |
| | | |
| | | const travelDaysText = computed(() => { |
| | | const d = r.value.travelDays ?? r.value.travel?.travelDays; |
| | | return d != null ? `${d} 天` : "â"; |
| | | }); |
| | | |
| | | const hasTravelStandard = computed(() => { |
| | | const row = r.value; |
| | | return ( |
| | | row.hotelStandard != null || |
| | | row.hotelDays != null || |
| | | row.livingSubsidy != null || |
| | | row.standardTag || |
| | | row.needSpecialApproval |
| | | ); |
| | | }); |
| | | |
| | | const subjectOptions = computed(() => |
| | | isTravel.value ? TRAVEL_SUBJECTS : COST_SUBJECTS |
| | | ); |
| | | |
| | | const detailRows = computed(() => { |
| | | const list = r.value.expenseDetails || r.value.details || []; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | const attachmentList = computed(() => { |
| | | const list = |
| | | r.value.attachmentList || |
| | | r.value.storageBlobVOList || |
| | | r.value.invoiceAttachments || |
| | | []; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | const approvalRecords = computed(() => { |
| | | const list = r.value.approvalRecords || []; |
| | | return Array.isArray(list) ? list : []; |
| | | }); |
| | | |
| | | /** æµç¨å±ç¤ºä¼å
ç¨ enrichment åç flowNodesï¼æ¥èª tasksï¼ */ |
| | | const flowNodesList = computed(() => { |
| | | const row = r.value; |
| | | if (Array.isArray(row.flowNodes) && row.flowNodes.length) { |
| | | return row.flowNodes; |
| | | } |
| | | if (Array.isArray(row.tasks) && row.tasks.length) { |
| | | return mapTasksToFlowNodes(row.tasks); |
| | | } |
| | | return []; |
| | | }); |
| | | |
| | | function taskStatusLabel(status) { |
| | | return taskStatusText(status); |
| | | } |
| | | |
| | | function recordLabel(result) { |
| | | return recordActionLabel(result); |
| | | } |
| | | |
| | | function formatTime(t) { |
| | | if (!t) return "â"; |
| | | const s = parseTime(t, "{y}-{m}-{d} {h}:{i}"); |
| | | return s || String(t).replace("T", " ").slice(0, 16); |
| | | } |
| | | |
| | | function detailSubject(d) { |
| | | return ( |
| | | resolveExpenseSubjectLabel(d.expenseSubject || d.expenseCategory, { |
| | | isTravel: isTravel.value, |
| | | subjectOptions: subjectOptions.value, |
| | | }) || "æªéç§ç®" |
| | | ); |
| | | } |
| | | |
| | | function detailAmount(d) { |
| | | return formatDetailAmount(d.amount) || "â"; |
| | | } |
| | | |
| | | function avatarColor(name) { |
| | | return userAvatarColor(name); |
| | | } |
| | | |
| | | function resolveFileUrl(f) { |
| | | let url = f?.url || f?.downloadURL || f?.previewURL || f?.fileUrl || ""; |
| | | if (!url) return ""; |
| | | if (/^https?:\/\//i.test(url)) return url; |
| | | const base = (config.baseUrl || "").replace(/\/+$/, ""); |
| | | const path = url.startsWith("/") ? url : `/${url}`; |
| | | return `${base}${path}`; |
| | | } |
| | | |
| | | function openAttachment(f) { |
| | | const url = resolveFileUrl(f); |
| | | if (!url) { |
| | | uni.showToast({ title: "æ æ³æå¼éä»¶", icon: "none" }); |
| | | return; |
| | | } |
| | | // #ifdef H5 |
| | | window.open(url, "_blank"); |
| | | // #endif |
| | | // #ifndef H5 |
| | | uni.downloadFile({ |
| | | url, |
| | | success: res => { |
| | | if (res.statusCode === 200) { |
| | | uni.openDocument({ filePath: res.tempFilePath, showMenu: true }); |
| | | } |
| | | }, |
| | | fail: () => uni.showToast({ title: "éä»¶æå¼å¤±è´¥", icon: "none" }), |
| | | }); |
| | | // #endif |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../reimburse-detail/reimburse-detail.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | export const EXPENSE_CATEGORY_OPTIONS = [ |
| | | { label: "å·®æ
", value: "travel" }, |
| | | { label: "åå
¬éè´", value: "office_procurement" }, |
| | | { label: "ä¸å¡æå¾
", value: "business_entertainment" }, |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "é讯费", value: "communication" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | export const EXPENSE_SUBJECT_OPTIONS = [ |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "ä½å®¿è´¹", value: "hotel" }, |
| | | { label: "é¤é¥®è´¹", value: "meal" }, |
| | | { label: "åå
¬ç¨å", value: "office_supply" }, |
| | | { label: "æå¾
è´¹", value: "entertainment" }, |
| | | { label: "é讯费", value: "phone" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | export const CATEGORY_TEMPLATES = { |
| | | travel: { |
| | | label: "å·®æ
è´¹ç¨", |
| | | reason: "å å
¬åºå·®äº§çç交éãä½å®¿ãé¤é¥®çè´¹ç¨æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "transport", description: "å¾è¿äº¤éè´¹" }, |
| | | { expenseSubject: "hotel", description: "ä½å®¿è´¹" }, |
| | | { expenseSubject: "meal", description: "åºå·®é¤é¥®" }, |
| | | ], |
| | | }, |
| | | office_procurement: { |
| | | label: "åå
¬éè´", |
| | | reason: "é¨é¨æ¥å¸¸åå
¬ç¨åãèæéè´æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "office_supply", description: "åå
¬ç¨åéè´" }, |
| | | { expenseSubject: "office_supply", description: "æå°èæ" }, |
| | | ], |
| | | }, |
| | | business_entertainment: { |
| | | label: "ä¸å¡æå¾
", |
| | | reason: "å®¢æ·æ¥å¾
ãåå¡å®´è¯·çè´¹ç¨æ¥éã", |
| | | details: [ |
| | | { expenseSubject: "entertainment", description: "å®¢æ·æ¥å¾
é¤è´¹" }, |
| | | { expenseSubject: "entertainment", description: "åå¡ç¤¼å" }, |
| | | ], |
| | | }, |
| | | transport: { |
| | | label: "交éè´¹", |
| | | reason: "å¸å
éå¤ãæè½¦ãå车ç交éè´¹ç¨æ¥éã", |
| | | details: [{ expenseSubject: "transport", description: "å¸å
交é" }], |
| | | }, |
| | | communication: { |
| | | label: "é讯费", |
| | | reason: "å å
¬éè®¯ãæµéãè¯è´¹è¡¥è´´æ¥éã", |
| | | details: [{ expenseSubject: "phone", description: "è¯è´¹/æµé" }], |
| | | }, |
| | | other: { |
| | | label: "å
¶ä»è´¹ç¨", |
| | | reason: "å
¶ä»å å
¬æ¯åºè´¹ç¨æ¥éã", |
| | | details: [{ expenseSubject: "other", description: "å
¶ä»è´¹ç¨" }], |
| | | }, |
| | | }; |
| | | |
| | | export function expenseSubjectLabel(v) { |
| | | return EXPENSE_SUBJECT_OPTIONS.find(x => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function expenseCategoryLabel(v) { |
| | | return EXPENSE_CATEGORY_OPTIONS.find(x => x.value === v)?.label || v || "â"; |
| | | } |
| | | |
| | | export function expenseTypeToCategory(expenseType) { |
| | | const t = (expenseType || "").trim(); |
| | | const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.label === t || x.value === t); |
| | | return hit?.value || "other"; |
| | | } |
| | | |
| | | export function createEmptyExpenseDetail() { |
| | | return { |
| | | id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }; |
| | | } |
| | | |
| | | export function createEmptyCostForm() { |
| | | return { |
| | | reimbursementId: undefined, |
| | | applicantId: "", |
| | | employeeNo: "", |
| | | employeeName: "", |
| | | expenseCategory: "other", |
| | | reimburseReason: "", |
| | | applyAmount: "", |
| | | payee: "", |
| | | payeeAccount: "", |
| | | bankBranch: "", |
| | | expenseDetails: [], |
| | | attachmentList: [], |
| | | approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }], |
| | | deptId: "", |
| | | deptName: "", |
| | | }; |
| | | } |
| | | |
| | | export function applyCategoryTemplate(form, category) { |
| | | const tpl = CATEGORY_TEMPLATES[category]; |
| | | if (!tpl) return; |
| | | form.expenseCategory = category; |
| | | if (!form.reimburseReason?.trim()) form.reimburseReason = tpl.reason; |
| | | form.expenseDetails = (tpl.details || []).map(d => ({ |
| | | ...createEmptyExpenseDetail(), |
| | | expenseSubject: d.expenseSubject, |
| | | description: d.description, |
| | | invoiceDate: dayjs().format("YYYY-MM-DD"), |
| | | })); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { expenseSubjectLabel as costSubjectLabel } from "./costReimburseUtils.js"; |
| | | import { expenseSubjectLabel as travelSubjectLabel } from "./travelReimburseUtils.js"; |
| | | |
| | | /** è´¹ç¨ç§ç®å±ç¤ºï¼å
¼å®¹ value / 䏿 label / API expenseCategoryï¼ */ |
| | | export function resolveExpenseSubjectLabel(v, { isTravel = true, subjectOptions = [] } = {}) { |
| | | if (!v) return ""; |
| | | const labelFn = isTravel ? travelSubjectLabel : costSubjectLabel; |
| | | const t = labelFn(v); |
| | | if (t && t !== "â") return t; |
| | | const hit = subjectOptions.find(x => x.value === v || x.label === v); |
| | | return hit?.label || String(v); |
| | | } |
| | | |
| | | export function formatDetailAmount(amount) { |
| | | if (amount === "" || amount == null) return null; |
| | | const n = Number(amount); |
| | | if (Number.isNaN(n)) return String(amount); |
| | | return `${n} å
`; |
| | | } |
| | | |
| | | /** åè¡¨è¡æè¦ */ |
| | | export function buildExpenseDetailSummary(row, opts = {}) { |
| | | const subject = resolveExpenseSubjectLabel(row?.expenseSubject, opts) || "æªéç§ç®"; |
| | | const amount = formatDetailAmount(row?.amount); |
| | | const date = row?.invoiceDate || ""; |
| | | const desc = (row?.description || "").trim(); |
| | | const parts = []; |
| | | if (date) parts.push(date); |
| | | if (desc) parts.push(desc); |
| | | const sub = parts.length ? parts.join(" · ") : "ç¹å»è¯¦æ
å®åä¿¡æ¯"; |
| | | const incomplete = !row?.invoiceDate || !row?.expenseSubject || row?.amount === "" || row?.amount == null; |
| | | return { subject, amount: amount || "é颿ªå¡«", sub, incomplete }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { |
| | | mapApprovalRecords, |
| | | mapRecordResult, |
| | | mapTasksToFlowNodes, |
| | | } from "../../_utils/approveListUtils.js"; |
| | | |
| | | function formatDisplayTime(val) { |
| | | if (!val) return ""; |
| | | const s = parseTime(val, "{y}-{m}-{d} {h}:{i}"); |
| | | return s || String(val).replace("T", " ").slice(0, 16); |
| | | } |
| | | |
| | | function taskStatusToNodeStatus(taskStatus) { |
| | | const s = String(taskStatus ?? "").toUpperCase(); |
| | | if (["APPROVED", "COMPLETED", "FINISHED", "PASSED", "AGREE"].includes(s)) { |
| | | return "finish"; |
| | | } |
| | | if (["REJECTED", "REJECT", "REFUSE", "REFUSED"].includes(s)) { |
| | | return "error"; |
| | | } |
| | | if (["PENDING", "IN_APPROVAL", "PROCESS", "PROCESSING"].includes(s)) { |
| | | return "process"; |
| | | } |
| | | return "wait"; |
| | | } |
| | | |
| | | /** storageBlobVOList â 页é¢éä»¶ */ |
| | | export function mapReimbursementAttachments(source = {}) { |
| | | const list = |
| | | source.storageBlobVOList || |
| | | source.storageBlobDTOs || |
| | | source.storageBlobDTOS || |
| | | source.attachmentList || |
| | | source.invoiceAttachments || |
| | | []; |
| | | if (!Array.isArray(list)) return []; |
| | | return list.map((b, i) => ({ |
| | | ...b, |
| | | id: b.id ?? b.blobId ?? `att_${i}`, |
| | | name: |
| | | b.fileName || |
| | | b.originalFilename || |
| | | b.originalFileName || |
| | | b.blobName || |
| | | b.name || |
| | | "éä»¶", |
| | | url: |
| | | b.url || |
| | | b.fileUrl || |
| | | b.downloadUrl || |
| | | b.downloadURL || |
| | | b.previewUrl || |
| | | b.previewURL || |
| | | b.link || |
| | | "", |
| | | })); |
| | | } |
| | | |
| | | /** 审æ¹è®°å½å¨ tasks */ |
| | | export function mapTasksToApprovalRecords(tasks) { |
| | | const list = Array.isArray(tasks) ? tasks : []; |
| | | return list |
| | | .map((t, index) => ({ |
| | | id: t.id ?? index, |
| | | operatorName: t.approverName || t.operatorName || t.createUserName || "â", |
| | | result: mapRecordResult(t.approveAction ?? t.taskStatus ?? t.status), |
| | | opinion: t.approveComment || t.comment || t.opinion || "", |
| | | time: formatDisplayTime( |
| | | t.approveTime || t.finishTime || t.updateTime || t.createTime || "" |
| | | ), |
| | | levelNo: t.levelNo ?? t.taskLevel, |
| | | })) |
| | | .sort((a, b) => { |
| | | const la = Number(a.levelNo ?? 0); |
| | | const lb = Number(b.levelNo ?? 0); |
| | | if (la !== lb) return la - lb; |
| | | return String(a.time).localeCompare(String(b.time)); |
| | | }); |
| | | } |
| | | |
| | | export function mapTasksToApprovalFlowNodes(tasks) { |
| | | const grouped = mapTasksToFlowNodes(tasks); |
| | | return grouped.map((node, i) => { |
| | | const approvers = node.approvers || []; |
| | | const statuses = approvers.map(a => |
| | | taskStatusToNodeStatus(a.taskStatus ?? a.status) |
| | | ); |
| | | let nodeStatus = "wait"; |
| | | if (statuses.includes("error")) nodeStatus = "error"; |
| | | else if (statuses.length && statuses.every(s => s === "finish")) { |
| | | nodeStatus = "finish"; |
| | | } else if (statuses.includes("process")) nodeStatus = "process"; |
| | | |
| | | const names = approvers.map(a => a.approverName).filter(Boolean).join("ã"); |
| | | const opinions = approvers |
| | | .map(a => a.approveComment) |
| | | .filter(Boolean) |
| | | .join("ï¼"); |
| | | |
| | | return { |
| | | nodeOrder: node.levelNo ?? i + 1, |
| | | levelNo: node.levelNo ?? i + 1, |
| | | approveType: node.approveType || "AND", |
| | | approveTypeLabel: node.approveType === "OR" ? "æç¾" : "ä¼ç¾", |
| | | approvers, |
| | | approverName: names || "â", |
| | | approveOpinion: opinions, |
| | | nodeStatus, |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | export function computeApprovalFlowCurrentIndex(approvalFlowNodes = []) { |
| | | const list = approvalFlowNodes || []; |
| | | const processing = list.findIndex(n => n.nodeStatus === "process"); |
| | | if (processing >= 0) return processing; |
| | | const errorIdx = list.findIndex(n => n.nodeStatus === "error"); |
| | | if (errorIdx >= 0) return errorIdx; |
| | | return list.filter(n => n.nodeStatus === "finish").length; |
| | | } |
| | | |
| | | export function applyFinReimbursementDetailEnrichment(mapped, raw = {}) { |
| | | if (!mapped || typeof mapped !== "object") return mapped; |
| | | const source = { ...raw, ...mapped }; |
| | | const tasks = Array.isArray(source.tasks) ? source.tasks : []; |
| | | const attachments = mapReimbursementAttachments(source); |
| | | const approvalRecords = tasks.length |
| | | ? mapTasksToApprovalRecords(tasks) |
| | | : mapApprovalRecords(source.records || source.approvalRecords); |
| | | const approvalFlowNodes = tasks.length |
| | | ? mapTasksToApprovalFlowNodes(tasks) |
| | | : mapped.approvalFlowNodes || []; |
| | | const flowNodes = tasks.length |
| | | ? mapTasksToFlowNodes(tasks) |
| | | : mapped.flowNodes || mapped.nodes || []; |
| | | |
| | | return { |
| | | ...mapped, |
| | | tasks, |
| | | storageBlobVOList: attachments, |
| | | attachmentList: attachments, |
| | | invoiceAttachments: attachments, |
| | | approvalRecords, |
| | | approvalFlowNodes, |
| | | currentNodeIndex: computeApprovalFlowCurrentIndex(approvalFlowNodes), |
| | | rejectReason: |
| | | approvalRecords.find(r => r.result === "rejected")?.opinion || |
| | | source.rejectReason || |
| | | "", |
| | | flowNodes, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | |
| | | export const EXPENSE_SUBJECT_OPTIONS = [ |
| | | { label: "交éè´¹", value: "transport" }, |
| | | { label: "ä½å®¿è´¹", value: "hotel" }, |
| | | { label: "é¤é¥®è´¹", value: "meal" }, |
| | | { label: "å
¶ä»", value: "other" }, |
| | | ]; |
| | | |
| | | const TIER1_CITIES = ["å京", "䏿µ·", "广å·", "æ·±å³"]; |
| | | |
| | | export function expenseSubjectLabel(v) { |
| | | return EXPENSE_SUBJECT_OPTIONS.find(x => x.value === v)?.label || "â"; |
| | | } |
| | | |
| | | export function detectTravelTier(destination) { |
| | | const city = (destination || "").trim(); |
| | | if (!city) return "tier3"; |
| | | if (TIER1_CITIES.some(c => city.includes(c))) return "tier1"; |
| | | const tier2Keywords = ["æå·", "å京", "æ¦æ±", "æé½", "éåº", "西å®", "天津", "èå·", "é¿æ²", "éå·"]; |
| | | if (tier2Keywords.some(c => city.includes(c))) return "tier2"; |
| | | return "tier3"; |
| | | } |
| | | |
| | | export function getTravelStandardByTier(tier) { |
| | | const map = { |
| | | tier1: { hotelPerNight: 600, transportPerDay: 80, mealPerDay: 100, label: "ä¸çº¿åå¸" }, |
| | | tier2: { hotelPerNight: 450, transportPerDay: 60, mealPerDay: 80, label: "äºçº¿åå¸" }, |
| | | tier3: { hotelPerNight: 350, transportPerDay: 40, mealPerDay: 60, label: "å
¶ä»åå¸" }, |
| | | }; |
| | | return map[tier] || map.tier3; |
| | | } |
| | | |
| | | export function computeTravelDays(startStr, endStr) { |
| | | if (!startStr || !endStr) return null; |
| | | const t0 = dayjs(startStr); |
| | | const t1 = dayjs(endStr); |
| | | if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null; |
| | | return Math.max(1, Math.ceil(t1.diff(t0, "day", true))); |
| | | } |
| | | |
| | | export function createEmptyExpenseDetail() { |
| | | return { |
| | | id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }; |
| | | } |
| | | |
| | | export function createEmptyTravelForm() { |
| | | return { |
| | | reimbursementId: undefined, |
| | | applicantId: "", |
| | | employeeNo: "", |
| | | employeeName: "", |
| | | reimburseReason: "", |
| | | travelStartTime: "", |
| | | travelEndTime: "", |
| | | travelDays: undefined, |
| | | departurePlace: "", |
| | | destination: "", |
| | | hotelStandard: undefined, |
| | | hotelDays: undefined, |
| | | livingSubsidy: undefined, |
| | | transportSubsidy: undefined, |
| | | lodgingLimit: undefined, |
| | | applyAmount: "", |
| | | payee: "", |
| | | payeeAccount: "", |
| | | payeeBank: "", |
| | | expenseDetails: [], |
| | | attachmentList: [], |
| | | approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }], |
| | | needSpecialApproval: false, |
| | | deptId: "", |
| | | deptName: "", |
| | | travelTier: "tier3", |
| | | standardTag: "", |
| | | }; |
| | | } |
| | |
| | | <!-- |
| | | OA / æ¥é管ç / è´¹ç¨æ¥é |
| | | è·¯ç±ï¼/pages/oa/ReimburseManage/cost-reimburse/index |
| | | OA / æ¥é管ç / è´¹ç¨æ¥éï¼/finReimbursement/listPageï¼reimbursementType=2ï¼ |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <FinReimbursementListPage :module-key="APPROVAL_MODULE_KEYS.COST_REIMBURSE" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - æ¥é管ç - è´¹ç¨æ¥é */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "ReimburseManage/cost-reimburse"; |
| | | const { config } = useOaPage(pageKey); |
| | | import FinReimbursementListPage from "../../_components/FinReimbursementListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥é详æ
页 |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page reimburse-detail-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <view v-if="loading" |
| | | class="rd-loading-wrap"> |
| | | <up-loading-icon mode="circle" /> |
| | | <text class="rd-loading-text">å è½½ä¸...</text> |
| | | </view> |
| | | |
| | | <scroll-view v-else-if="reimburseRow" |
| | | class="oa-detail-scroll reimburse-detail-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <ReimburseInstanceDetailBody :reimburse-row="reimburseRow" |
| | | :module-key="moduleKey" /> |
| | | </scroll-view> |
| | | |
| | | <view v-else |
| | | class="oa-empty"> |
| | | <up-empty mode="data" |
| | | text="æªè·åå°æ¥éæ°æ®" /> |
| | | </view> |
| | | |
| | | <view v-if="reimburseRow && canEdit" |
| | | class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | @click="goBack">è¿å</text> |
| | | <text class="oa-footer-btn btn-primary" |
| | | @click="goEdit">ä¿®æ¹</text> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ReimburseInstanceDetailBody from "../_components/ReimburseInstanceDetailBody.vue"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | canEditReimbursementRow, |
| | | fetchFinReimbursementListItemDetail, |
| | | resolveReimbursementDeleteId, |
| | | } from "../../_utils/finReimbursementMappers.js"; |
| | | |
| | | const moduleKey = ref(""); |
| | | const reimbursementId = ref(""); |
| | | const reimburseRow = ref(null); |
| | | const loading = ref(false); |
| | | |
| | | const pageTitle = computed( |
| | | () => `${getApprovalModuleConfig(moduleKey.value)?.label || "æ¥é"}详æ
` |
| | | ); |
| | | |
| | | const canEdit = computed(() => |
| | | reimburseRow.value ? canEditReimbursementRow(reimburseRow.value) : false |
| | | ); |
| | | |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const goEdit = () => { |
| | | const rid = resolveReimbursementDeleteId(reimburseRow.value); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${moduleKey.value}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | }; |
| | | |
| | | onLoad(async options => { |
| | | moduleKey.value = options?.moduleKey || ""; |
| | | reimbursementId.value = options?.reimbursementId || ""; |
| | | if (!moduleKey.value || !reimbursementId.value) { |
| | | uni.showToast({ title: "åæ°ä¸å®æ´", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | reimburseRow.value = await fetchFinReimbursementListItemDetail( |
| | | { reimbursementId: reimbursementId.value }, |
| | | moduleKey.value |
| | | ); |
| | | if (reimburseRow.value?.moduleKey) { |
| | | moduleKey.value = reimburseRow.value.moduleKey; |
| | | } |
| | | } catch { |
| | | uni.showToast({ title: "å 载详æ
失败", icon: "none" }); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | @import "./reimburse-detail.scss"; |
| | | |
| | | .rd-loading-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 80px 0; |
| | | } |
| | | .rd-loading-text { |
| | | margin-top: 12px; |
| | | font-size: 14px; |
| | | color: #909399; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | .reimburse-detail-page { |
| | | min-height: 100vh; |
| | | background: #f2f4f7; |
| | | } |
| | | |
| | | .reimburse-detail-scroll { |
| | | padding-bottom: calc(72px + env(safe-area-inset-bottom)); |
| | | } |
| | | |
| | | .rd-hero { |
| | | margin: 12px 16px 0; |
| | | padding: 16px; |
| | | background: linear-gradient(135deg, #f0f7ff 0%, #fff 55%); |
| | | border-radius: 12px; |
| | | box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); |
| | | border: 1px solid #e8f0fe; |
| | | } |
| | | |
| | | .rd-hero-top { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .rd-bill-no { |
| | | font-size: 13px; |
| | | color: #8c8c8c; |
| | | flex: 1; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .rd-status { |
| | | flex-shrink: 0; |
| | | font-size: 11px; |
| | | padding: 5px 8px; |
| | | border-radius: 4px; |
| | | font-weight: 500; |
| | | |
| | | &.status-pending { |
| | | color: #d46b08; |
| | | background: #fff7e6; |
| | | } |
| | | &.status-approved { |
| | | color: #389e0d; |
| | | background: #f6ffed; |
| | | } |
| | | &.status-rejected { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | } |
| | | &.status-draft { |
| | | color: #595959; |
| | | background: #f5f5f5; |
| | | } |
| | | &.status-cancelled { |
| | | color: #8c8c8c; |
| | | background: #fafafa; |
| | | } |
| | | } |
| | | |
| | | .rd-reason { |
| | | display: block; |
| | | margin-top: 10px; |
| | | font-size: 17px; |
| | | font-weight: 600; |
| | | color: #1a1a1a; |
| | | line-height: 1.45; |
| | | } |
| | | |
| | | .rd-amount-row { |
| | | display: flex; |
| | | align-items: baseline; |
| | | justify-content: space-between; |
| | | margin-top: 14px; |
| | | padding-top: 12px; |
| | | border-top: 1px dashed #e8ecf0; |
| | | } |
| | | |
| | | .rd-amount-label { |
| | | font-size: 14px; |
| | | color: #8c8c8c; |
| | | } |
| | | |
| | | .rd-amount { |
| | | font-size: 22px; |
| | | font-weight: 700; |
| | | color: #2979ff; |
| | | } |
| | | |
| | | .rd-section { |
| | | margin: 12px 16px 0; |
| | | } |
| | | |
| | | .rd-section-hd { |
| | | padding: 4px 4px 8px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | } |
| | | |
| | | .rd-section-title { |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | color: #909399; |
| | | } |
| | | |
| | | .rd-section-count { |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | } |
| | | |
| | | .rd-group { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04); |
| | | } |
| | | |
| | | .rd-cell { |
| | | display: flex; |
| | | align-items: flex-start; |
| | | padding: 13px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | font-size: 14px; |
| | | line-height: 1.45; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-label { |
| | | width: 88px; |
| | | flex-shrink: 0; |
| | | color: #8c8c8c; |
| | | } |
| | | |
| | | .rd-value { |
| | | flex: 1; |
| | | color: #303133; |
| | | text-align: right; |
| | | word-break: break-all; |
| | | } |
| | | |
| | | .rd-detail-item { |
| | | padding: 14px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-detail-head { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .rd-detail-badge { |
| | | width: 24px; |
| | | height: 24px; |
| | | border-radius: 6px; |
| | | background: #ecf5ff; |
| | | color: #2979ff; |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | text-align: center; |
| | | line-height: 24px; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | | .rd-detail-title { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-detail-amount { |
| | | margin-left: auto; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #2979ff; |
| | | } |
| | | |
| | | .rd-flow-node { |
| | | display: flex; |
| | | padding: 12px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-flow-line { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | margin-right: 12px; |
| | | width: 20px; |
| | | } |
| | | |
| | | .rd-flow-dot { |
| | | width: 10px; |
| | | height: 10px; |
| | | border-radius: 50%; |
| | | background: #2979ff; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rd-flow-bar { |
| | | flex: 1; |
| | | width: 2px; |
| | | min-height: 20px; |
| | | background: #e4e7ed; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .rd-flow-body { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rd-flow-level { |
| | | font-size: 14px; |
| | | font-weight: 500; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-flow-type { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 2px; |
| | | } |
| | | |
| | | .rd-flow-approver { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .rd-flow-avatar { |
| | | width: 28px; |
| | | height: 28px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | | .rd-flow-approver-meta { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rd-flow-name { |
| | | display: block; |
| | | font-size: 14px; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-flow-status { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 2px; |
| | | } |
| | | |
| | | .rd-record-item { |
| | | padding: 14px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | } |
| | | |
| | | .rd-record-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .rd-record-operator { |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rd-record-tag { |
| | | font-size: 11px; |
| | | padding: 2px 8px; |
| | | border-radius: 4px; |
| | | flex-shrink: 0; |
| | | |
| | | &--approved { |
| | | color: #389e0d; |
| | | background: #f6ffed; |
| | | } |
| | | &--rejected { |
| | | color: #cf1322; |
| | | background: #fff1f0; |
| | | } |
| | | &--pending { |
| | | color: #d46b08; |
| | | background: #fff7e6; |
| | | } |
| | | } |
| | | |
| | | .rd-record-time { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .rd-record-opinion { |
| | | display: block; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | margin-top: 6px; |
| | | line-height: 1.45; |
| | | } |
| | | |
| | | .rd-empty { |
| | | padding: 20px; |
| | | text-align: center; |
| | | font-size: 13px; |
| | | color: #c0c4cc; |
| | | } |
| | | |
| | | .rd-attach { |
| | | padding: 12px 16px; |
| | | font-size: 14px; |
| | | color: #2979ff; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥éæ°å¢/ç¼è¾ï¼ä¸ Web åæ®µä¸è´ï¼ç§»å¨ç«¯ä¼åé人/å¸å±ï¼ |
| | | --> |
| | | <template> |
| | | <view class="oa-detail-page reimburse-form-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | <scroll-view class="oa-detail-scroll reimburse-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false"> |
| | | <view v-if="loading" |
| | | class="rf-loading">å è½½ä¸...</view> |
| | | |
| | | <view v-else> |
| | | <!-- ç³è¯·äºº --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">ç³è¯·äºº</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-applicant-card" |
| | | :class="{ 'is-empty': !form.applicantId }" |
| | | @click="showApplicantPicker = true"> |
| | | <view class="rf-applicant-avatar" |
| | | :style="{ backgroundColor: applicantAvatarColor }"> |
| | | {{ (form.employeeName || 'é').charAt(0) }} |
| | | </view> |
| | | <view class="rf-applicant-meta"> |
| | | <text class="rf-applicant-name">{{ form.employeeName || 'è¯·éæ©åå·¥' }}</text> |
| | | <text class="rf-applicant-sub">{{ applicantDisplaySub }}</text> |
| | | </view> |
| | | <text class="rf-applicant-action">{{ form.applicantId ? 'æ´æ¢' : 'éæ©' }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- åºæ¬ä¿¡æ¯ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">åºæ¬ä¿¡æ¯</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-cell rf-cell--col"> |
| | | <text class="rf-label required">æ¥éåå </text> |
| | | <view class="rf-textarea-wrap"> |
| | | <up-textarea v-model="form.reimburseReason" |
| | | placeholder="请填ååºå·®åæ¥éåå " |
| | | maxlength="2000" |
| | | border="none" |
| | | height="80" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <template v-if="isTravel"> |
| | | <view class="rf-cell rf-cell--tap" |
| | | @click="openDatePicker('travelStartTime')"> |
| | | <text class="rf-label required">åºå·®å¼å§</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value" |
| | | :class="{ placeholder: !form.travelStartTime }"> |
| | | {{ form.travelStartTime || 'è¯·éæ©' }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell rf-cell--tap" |
| | | @click="openDatePicker('travelEndTime')"> |
| | | <text class="rf-label required">åºå·®ç»æ</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value" |
| | | :class="{ placeholder: !form.travelEndTime }"> |
| | | {{ form.travelEndTime || 'è¯·éæ©' }} |
| | | </text> |
| | | <up-icon name="calendar" |
| | | size="18" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">åºå·®å¤©æ°</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value">{{ travelDaysDisplay || 'â' }}</text> |
| | | <text class="rf-value" |
| | | style="color:#909399;margin-left:4px">天</text> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">åºå·®å°</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.departurePlace" |
| | | placeholder="åºååå¸" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">ç®çå°</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.destination" |
| | | placeholder="ç®çåå¸" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <template v-else> |
| | | <view class="rf-cell rf-cell--tap" |
| | | @click="showCategorySheet = true"> |
| | | <text class="rf-label required">è´¹ç¨ç±»å</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value" |
| | | :class="{ placeholder: !form.expenseCategory }">{{ categoryLabel }}</text> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-chips"> |
| | | <text v-for="cat in quickCategories" |
| | | :key="cat.value" |
| | | class="rf-chip" |
| | | :class="{ active: form.expenseCategory === cat.value }" |
| | | @click="applyTemplate(cat.value)">{{ cat.label }}</text> |
| | | </view> |
| | | </template> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å·®æ
æ å --> |
| | | <view v-if="isTravel" |
| | | class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">å·®æ
æ å</text> |
| | | <text class="rf-section-extra">{{ travelTierLabel }}</text> |
| | | </view> |
| | | <view v-if="overBudgetWarnings.length" |
| | | class="rf-warn-box"> |
| | | <text v-for="(w, i) in overBudgetWarnings" |
| | | :key="i" |
| | | class="rf-warn-line">{{ w }}</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">é
åºæ å</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.hotelStandard" |
| | | type="digit" |
| | | placeholder="å
/æ" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">ä½å®¿å¤©æ°</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.hotelDays" |
| | | type="number" |
| | | border="none" |
| | | input-align="right" |
| | | @blur="recalcTravelStandards" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">çæ´»è¡¥è´´</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.livingSubsidy" |
| | | type="digit" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">交é补贴</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value">建议 {{ suggestedTransportSubsidy }} å
</text> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">ä½å®¿éé¢</text> |
| | | <view class="rf-value-wrap"> |
| | | <text class="rf-value">建议 {{ suggestedHotelLimit }} å
</text> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">ç¹æ¹æ è®°</text> |
| | | <text class="rf-tag" |
| | | :class="form.needSpecialApproval ? 'rf-tag--danger' : 'rf-tag--ok'"> |
| | | {{ form.needSpecialApproval ? 'è¶
æ¯éç¹æ¹' : '卿 åå
' }} |
| | | </text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- éé¢ä¸æ¶æ¬¾ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">éé¢ä¸æ¶æ¬¾</text> |
| | | <text class="rf-section-extra" |
| | | @click="syncApplyAmountFromDetails">ææç» {{ detailTotalAmount }} å
</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">ç³è¯·éé¢</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.applyAmount" |
| | | type="digit" |
| | | placeholder="å
" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label required">æ¶æ¬¾äºº</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.payee" |
| | | placeholder="æ¶æ¬¾äºº" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">æ¶æ¬¾è´¦å·</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-model="form.payeeAccount" |
| | | placeholder="éå¡«" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | <view class="rf-cell"> |
| | | <text class="rf-label">弿·æ¯è¡</text> |
| | | <view class="rf-input-body"> |
| | | <up-input v-if="isTravel" |
| | | v-model="form.payeeBank" |
| | | placeholder="éå¡«" |
| | | border="none" |
| | | input-align="right" /> |
| | | <up-input v-else |
| | | v-model="form.bankBranch" |
| | | placeholder="éå¡«" |
| | | border="none" |
| | | input-align="right" /> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- æ¥éæç»ï¼å表æè¦ + 详æ
æé® --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">æ¥éæç»</text> |
| | | <text class="rf-section-extra" |
| | | @click="addAndOpenDetail">+ æ°å¢</text> |
| | | </view> |
| | | <view class="rf-group" |
| | | v-if="form.expenseDetails.length"> |
| | | <view v-for="(row, idx) in form.expenseDetails" |
| | | :key="row.id || idx" |
| | | class="rf-detail-row" |
| | | :class="{ 'rf-detail-row--warn': detailSummary(row).incomplete }" |
| | | @click="openDetailEditor(idx)"> |
| | | <view class="rf-detail-index">{{ idx + 1 }}</view> |
| | | <view class="rf-detail-body"> |
| | | <view class="rf-detail-line1"> |
| | | <text class="rf-detail-subject">{{ detailSummary(row).subject }}</text> |
| | | <text class="rf-detail-amount">{{ detailSummary(row).amount }}</text> |
| | | </view> |
| | | <text class="rf-detail-line2">{{ detailSummary(row).sub }}</text> |
| | | </view> |
| | | <text class="rf-detail-action" |
| | | @click.stop="openDetailEditor(idx)">详æ
</text> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="rf-group"> |
| | | <view class="rf-empty" |
| | | @click="addAndOpenDetail">ç¹å»æ·»å æ¥éæç»</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- éä»¶ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">éä»¶ï¼å票ï¼</text> |
| | | </view> |
| | | <view class="rf-group"> |
| | | <view v-for="(f, i) in form.attachmentList" |
| | | :key="i" |
| | | class="rf-attach-item"> |
| | | <text>{{ f.name || 'éä»¶' }}</text> |
| | | <text class="rf-detail-del" |
| | | @click="removeAttachment(i)">å é¤</text> |
| | | </view> |
| | | <view class="rf-upload-zone" |
| | | @click="chooseAttachment"> |
| | | <up-icon name="plus-circle" |
| | | size="22" |
| | | color="#2979ff" /> |
| | | <text>ä¸ä¼ å票/éä»¶</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- å®¡æ¹æµç¨ --> |
| | | <view class="rf-section"> |
| | | <view class="rf-section-hd"> |
| | | <text class="rf-section-title">å®¡æ¹æµç¨</text> |
| | | </view> |
| | | <view class="rf-group" |
| | | style="padding:12px"> |
| | | <ReimburseApprovalFlowEditor v-model="form.approvalFlowNodes" |
| | | :user-options="flowUserOptions" /> |
| | | <text class="rf-hint-row">æ¯çº§é¡»æå®å®¡æ¹äººï¼æ¯ææç´¢å§åæå·¥å·</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <view class="oa-page-footer"> |
| | | <text class="oa-footer-btn btn-default" |
| | | @click="goBack">åæ¶</text> |
| | | <text class="oa-footer-btn btn-primary" |
| | | :class="{ 'is-disabled': submitting }" |
| | | @click="onSubmit">æäº¤</text> |
| | | </view> |
| | | |
| | | <OaUserSearchPicker v-model:show="showApplicantPicker" |
| | | v-model="form.applicantId" |
| | | title="éæ©ç³è¯·äºº" |
| | | :users="flowUserOptions" |
| | | @select="onApplicantPicked" /> |
| | | |
| | | <up-action-sheet :show="showCategorySheet" |
| | | title="è´¹ç¨ç±»å" |
| | | :actions="categoryActions" |
| | | @select="onCategorySelect" |
| | | @close="showCategorySheet = false" /> |
| | | |
| | | <ReimburseExpenseDetailSheet v-model:show="showDetailSheet" |
| | | v-model="detailDraft" |
| | | :index="editingDetailIndex" |
| | | :is-travel="isTravel" |
| | | :subject-options="expenseSubjectOptions" |
| | | @confirm="onDetailSheetConfirm" |
| | | @delete="onDetailSheetDelete" /> |
| | | |
| | | <up-popup :show="showDatePicker" |
| | | mode="bottom" |
| | | round="16" |
| | | @close="showDatePicker = false"> |
| | | <up-datetime-picker :show="true" |
| | | v-model="datePickerTs" |
| | | mode="datetime" |
| | | @confirm="onDateConfirm" |
| | | @cancel="showDatePicker = false" /> |
| | | </up-popup> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue"; |
| | | import ReimburseExpenseDetailSheet from "../_components/ReimburseExpenseDetailSheet.vue"; |
| | | import config from "@/config.js"; |
| | | import { getToken } from "@/utils/auth"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { consumeReimburseEditFromApprove } from "../../_utils/reimburseApproveBridge.js"; |
| | | import { EXPENSE_CATEGORY_OPTIONS } from "../_utils/costReimburseUtils.js"; |
| | | import { buildExpenseDetailSummary } from "../_utils/expenseDetailDisplay.js"; |
| | | import ReimburseApprovalFlowEditor from "../_components/ReimburseApprovalFlowEditor.vue"; |
| | | import { useFinReimburseForm } from "./useFinReimburseForm.js"; |
| | | |
| | | const moduleKey = ref(""); |
| | | const mode = ref("add"); |
| | | const reimbursementId = ref(""); |
| | | |
| | | const { |
| | | form, |
| | | isTravel, |
| | | submitting, |
| | | loading, |
| | | flowUserOptions, |
| | | travelDaysDisplay, |
| | | travelTierLabel, |
| | | suggestedTransportSubsidy, |
| | | suggestedHotelLimit, |
| | | detailTotalAmount, |
| | | overBudgetWarnings, |
| | | expenseSubjectOptions, |
| | | categoryActions, |
| | | categoryLabel, |
| | | showApplicantPicker, |
| | | applicantDisplaySub, |
| | | applicantAvatarColor, |
| | | showCategorySheet, |
| | | loadUserPool, |
| | | onApplicantPicked, |
| | | recalcTravelStandards, |
| | | syncApplyAmountFromDetails, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | applyTemplate, |
| | | initForm, |
| | | loadEdit, |
| | | submitForm, |
| | | } = useFinReimburseForm(moduleKey, mode); |
| | | |
| | | const showDatePicker = ref(false); |
| | | const datePickerField = ref(""); |
| | | const datePickerTs = ref(Date.now()); |
| | | |
| | | const showDetailSheet = ref(false); |
| | | const editingDetailIndex = ref(0); |
| | | const detailDraft = reactive({ |
| | | invoiceDate: "", |
| | | expenseSubject: "", |
| | | amount: "", |
| | | description: "", |
| | | }); |
| | | |
| | | const quickCategories = EXPENSE_CATEGORY_OPTIONS.slice(0, 4); |
| | | |
| | | const pageTitle = computed(() => { |
| | | const label = getApprovalModuleConfig(moduleKey.value)?.label || "æ¥é"; |
| | | return mode.value === "edit" ? `ç¼è¾${label}` : `æ°å¢${label}`; |
| | | }); |
| | | |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | function detailSummary(row) { |
| | | return buildExpenseDetailSummary(row, { |
| | | isTravel: isTravel.value, |
| | | subjectOptions: expenseSubjectOptions.value, |
| | | }); |
| | | } |
| | | |
| | | function openDetailEditor(idx) { |
| | | editingDetailIndex.value = idx; |
| | | const row = form.expenseDetails[idx]; |
| | | if (!row) return; |
| | | Object.assign(detailDraft, JSON.parse(JSON.stringify(row))); |
| | | showDetailSheet.value = true; |
| | | } |
| | | |
| | | function addAndOpenDetail() { |
| | | addExpenseDetail(); |
| | | openDetailEditor(form.expenseDetails.length - 1); |
| | | } |
| | | |
| | | function onDetailSheetConfirm(data) { |
| | | const idx = editingDetailIndex.value; |
| | | if (form.expenseDetails[idx]) { |
| | | Object.assign(form.expenseDetails[idx], data); |
| | | } |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function onDetailSheetDelete() { |
| | | const idx = editingDetailIndex.value; |
| | | removeExpenseDetail(idx); |
| | | showDetailSheet.value = false; |
| | | } |
| | | |
| | | function onCategorySelect(action) { |
| | | form.expenseCategory = action.value; |
| | | applyTemplate(action.value); |
| | | showCategorySheet.value = false; |
| | | } |
| | | |
| | | function openDatePicker(field) { |
| | | datePickerField.value = field; |
| | | detailDateIndex.value = -1; |
| | | datePickerTs.value = Date.now(); |
| | | showDatePicker.value = true; |
| | | } |
| | | |
| | | function onDateConfirm(e) { |
| | | const ts = e?.value ?? datePickerTs.value; |
| | | if (datePickerField.value) { |
| | | form[datePickerField.value] = parseTime(ts, "{y}-{m}-{d} {h}:{i}:{s}"); |
| | | recalcTravelStandards(); |
| | | } |
| | | showDatePicker.value = false; |
| | | } |
| | | |
| | | function chooseAttachment() { |
| | | uni.chooseImage({ |
| | | count: 9, |
| | | success: res => { |
| | | (res.tempFilePaths || []).forEach(path => uploadOne(path)); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | function uploadOne(filePath) { |
| | | uni.uploadFile({ |
| | | url: `${config.baseUrl}/file/upload`, |
| | | filePath, |
| | | name: "file", |
| | | header: { Authorization: "Bearer " + getToken() }, |
| | | success: res => { |
| | | try { |
| | | const data = JSON.parse(res.data || "{}"); |
| | | const url = data.url || data.data?.url || ""; |
| | | const name = data.originalFilename || data.fileName || "éä»¶"; |
| | | if (!form.attachmentList) form.attachmentList = []; |
| | | form.attachmentList.push({ name, url }); |
| | | } catch { |
| | | uni.showToast({ title: "ä¸ä¼ è§£æå¤±è´¥", icon: "none" }); |
| | | } |
| | | }, |
| | | fail: () => uni.showToast({ title: "ä¸ä¼ 失败", icon: "none" }), |
| | | }); |
| | | } |
| | | |
| | | function removeAttachment(i) { |
| | | form.attachmentList.splice(i, 1); |
| | | } |
| | | |
| | | async function onSubmit() { |
| | | const ok = await submitForm(); |
| | | if (ok) setTimeout(goBack, 400); |
| | | } |
| | | |
| | | onLoad(async options => { |
| | | moduleKey.value = options?.moduleKey || ""; |
| | | mode.value = options?.mode === "edit" ? "edit" : "add"; |
| | | reimbursementId.value = options?.reimbursementId || ""; |
| | | const fromApprove = consumeReimburseEditFromApprove(); |
| | | if (fromApprove?.moduleKey) { |
| | | moduleKey.value = fromApprove.moduleKey; |
| | | mode.value = "edit"; |
| | | reimbursementId.value = String(fromApprove.reimbursementId ?? ""); |
| | | } |
| | | if (!moduleKey.value) { |
| | | uni.showToast({ title: "ç¼ºå°æ¨¡åç±»å", icon: "none" }); |
| | | setTimeout(goBack, 500); |
| | | return; |
| | | } |
| | | await loadUserPool(); |
| | | await initForm(); |
| | | if (mode.value === "edit" && reimbursementId.value) { |
| | | try { |
| | | await loadEdit(reimbursementId.value); |
| | | } catch { |
| | | uni.showToast({ title: "å 载失败", icon: "none" }); |
| | | } |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "../../_styles/oa-approval-list.scss"; |
| | | @import "./reimburse-form.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | .reimburse-form-page { |
| | | min-height: 100vh; |
| | | background: #f2f4f7; |
| | | } |
| | | |
| | | .reimburse-scroll { |
| | | padding-bottom: calc(80px + env(safe-area-inset-bottom)); |
| | | } |
| | | |
| | | .rf-section { |
| | | margin: 12px 16px 0; |
| | | } |
| | | |
| | | .rf-section-hd { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 4px 4px 8px; |
| | | } |
| | | |
| | | .rf-section-title { |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | color: #909399; |
| | | letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .rf-section-extra { |
| | | font-size: 13px; |
| | | color: #2979ff; |
| | | } |
| | | |
| | | .rf-group { |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); |
| | | } |
| | | |
| | | .rf-applicant-card { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 16px; |
| | | background: linear-gradient(135deg, #f8fbff 0%, #fff 60%); |
| | | border-bottom: 1px solid #f0f2f5; |
| | | |
| | | &.is-empty { |
| | | background: #fff; |
| | | } |
| | | } |
| | | |
| | | .rf-applicant-avatar { |
| | | width: 48px; |
| | | height: 48px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-applicant-meta { |
| | | flex: 1; |
| | | margin-left: 12px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rf-applicant-name { |
| | | font-size: 17px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | |
| | | .rf-applicant-sub { |
| | | font-size: 13px; |
| | | color: #909399; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .rf-applicant-action { |
| | | font-size: 14px; |
| | | color: #2979ff; |
| | | padding: 6px 12px; |
| | | background: #ecf5ff; |
| | | border-radius: 16px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-cell { |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 52px; |
| | | padding: 12px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | &--tap:active { |
| | | background: #f9fafb; |
| | | } |
| | | |
| | | &--col { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | min-height: auto; |
| | | padding-bottom: 14px; |
| | | } |
| | | } |
| | | |
| | | .rf-label { |
| | | width: 88px; |
| | | flex-shrink: 0; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | |
| | | &.required::before { |
| | | content: "*"; |
| | | color: #f56c6c; |
| | | margin-right: 2px; |
| | | } |
| | | } |
| | | |
| | | .rf-value-wrap { |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | min-width: 0; |
| | | gap: 4px; |
| | | } |
| | | |
| | | .rf-value { |
| | | font-size: 15px; |
| | | color: #303133; |
| | | text-align: right; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | |
| | | &.placeholder { |
| | | color: #c0c4cc; |
| | | } |
| | | } |
| | | |
| | | .rf-input-body { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rf-textarea-wrap { |
| | | width: 100%; |
| | | margin-top: 8px; |
| | | background: #f5f7fa; |
| | | border-radius: 8px; |
| | | padding: 4px 8px; |
| | | } |
| | | |
| | | .rf-inline-input { |
| | | text-align: right; |
| | | font-size: 15px; |
| | | } |
| | | |
| | | .rf-hint-row { |
| | | padding: 8px 16px 12px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .rf-warn-box { |
| | | margin: 0 16px 8px; |
| | | padding: 10px 12px; |
| | | background: #fdf6ec; |
| | | border-radius: 8px; |
| | | border-left: 3px solid #e6a23c; |
| | | } |
| | | |
| | | .rf-warn-line { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #e6a23c; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .rf-tag { |
| | | font-size: 13px; |
| | | padding: 4px 10px; |
| | | border-radius: 4px; |
| | | &--ok { |
| | | color: #67c23a; |
| | | background: #f0f9eb; |
| | | } |
| | | &--danger { |
| | | color: #f56c6c; |
| | | background: #fef0f0; |
| | | } |
| | | } |
| | | |
| | | .rf-chips { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | padding: 0 16px 14px; |
| | | } |
| | | |
| | | .rf-chip { |
| | | font-size: 13px; |
| | | padding: 6px 14px; |
| | | background: #f5f7fa; |
| | | color: #606266; |
| | | border-radius: 20px; |
| | | border: 1px solid #ebeef5; |
| | | |
| | | &.active { |
| | | background: #ecf5ff; |
| | | color: #2979ff; |
| | | border-color: #b3d8ff; |
| | | } |
| | | } |
| | | |
| | | .rf-detail-row { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 14px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | min-height: 64px; |
| | | |
| | | &:last-child { |
| | | border-bottom: none; |
| | | } |
| | | |
| | | &:active { |
| | | background: #f9fafb; |
| | | } |
| | | |
| | | &--warn .rf-detail-subject { |
| | | color: #e6a23c; |
| | | } |
| | | } |
| | | |
| | | .rf-detail-index { |
| | | width: 28px; |
| | | height: 28px; |
| | | border-radius: 8px; |
| | | background: #ecf5ff; |
| | | color: #2979ff; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-detail-body { |
| | | flex: 1; |
| | | margin: 0 10px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .rf-detail-line1 { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .rf-detail-subject { |
| | | font-size: 15px; |
| | | font-weight: 500; |
| | | color: #303133; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | flex: 1; |
| | | } |
| | | |
| | | .rf-detail-amount { |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #2979ff; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .rf-detail-line2 { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 4px; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .rf-detail-action { |
| | | flex-shrink: 0; |
| | | font-size: 14px; |
| | | color: #2979ff; |
| | | padding: 6px 12px; |
| | | background: #ecf5ff; |
| | | border-radius: 16px; |
| | | border: 1px solid #d9ecff; |
| | | } |
| | | |
| | | .rf-detail-del { |
| | | font-size: 13px; |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .rf-upload-zone { |
| | | margin: 0 16px 14px; |
| | | padding: 20px; |
| | | border: 1px dashed #c0c4cc; |
| | | border-radius: 10px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | gap: 6px; |
| | | color: #2979ff; |
| | | font-size: 14px; |
| | | background: #fafbfc; |
| | | } |
| | | |
| | | .rf-attach-item { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | padding: 10px 16px; |
| | | border-bottom: 1px solid #f5f6f8; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .rf-link { |
| | | font-size: 13px; |
| | | color: #2979ff; |
| | | padding: 4px 0; |
| | | } |
| | | |
| | | .rf-empty { |
| | | text-align: center; |
| | | padding: 20px; |
| | | color: #c0c4cc; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | .rf-loading { |
| | | padding: 60px; |
| | | text-align: center; |
| | | color: #909399; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { computed, reactive, ref } from "vue"; |
| | | import { userListNoPageByTenantId } from "@/api/system/user"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { persistFinReimbursement } from "@/api/oa/finReimbursement.js"; |
| | | import { |
| | | isActiveUser, |
| | | unwrapUserList, |
| | | userAvatarColor, |
| | | userSelectLabel, |
| | | userSubLabel, |
| | | } from "../../_utils/userPickerUtils.js"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | buildCostReimbursementSaveDto, |
| | | buildTravelReimbursementSaveDto, |
| | | fetchFinReimbursementFormDetail, |
| | | getReimbursementTypeByModuleKey, |
| | | validateReimbursementPersistDto, |
| | | } from "../../_utils/finReimbursementMappers.js"; |
| | | import { |
| | | applyCategoryTemplate, |
| | | createEmptyCostForm, |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | EXPENSE_SUBJECT_OPTIONS as COST_SUBJECT_OPTIONS, |
| | | createEmptyExpenseDetail as createCostDetail, |
| | | } from "../_utils/costReimburseUtils.js"; |
| | | import { |
| | | computeTravelDays, |
| | | createEmptyExpenseDetail, |
| | | createEmptyTravelForm, |
| | | detectTravelTier, |
| | | EXPENSE_SUBJECT_OPTIONS, |
| | | getTravelStandardByTier, |
| | | } from "../_utils/travelReimburseUtils.js"; |
| | | |
| | | const userStore = useUserStore(); |
| | | |
| | | function buildOverBudgetWarnings(f, detailTotal, hotelLimit, transportLimit, mealLimit) { |
| | | const warnings = []; |
| | | const bySubject = { transport: 0, hotel: 0, meal: 0, other: 0 }; |
| | | (f.expenseDetails || []).forEach(d => { |
| | | const key = d.expenseSubject || "other"; |
| | | bySubject[key] = (bySubject[key] || 0) + (Number(d.amount) || 0); |
| | | }); |
| | | if (bySubject.transport > transportLimit && transportLimit > 0) { |
| | | warnings.push(`交éè´¹ ${bySubject.transport} å
è¶
åºæ å ${transportLimit} å
`); |
| | | } |
| | | if (bySubject.hotel > hotelLimit && hotelLimit > 0) { |
| | | warnings.push(`ä½å®¿è´¹ ${bySubject.hotel} å
è¶
åºéé¢ ${hotelLimit} å
`); |
| | | } |
| | | if (bySubject.meal > mealLimit && mealLimit > 0) { |
| | | warnings.push(`é¤é¥®è´¹ ${bySubject.meal} å
è¶
åºç活补贴建议 ${mealLimit} å
`); |
| | | } |
| | | const std = getTravelStandardByTier(f.travelTier); |
| | | if (Number(f.hotelStandard) > std.hotelPerNight) { |
| | | warnings.push(`é
åºæ å ${f.hotelStandard} å
/æé«äº${std.label}æ å ${std.hotelPerNight} å
/æ`); |
| | | } |
| | | const apply = Number(f.applyAmount) || detailTotal; |
| | | const standardTotal = transportLimit + hotelLimit + mealLimit; |
| | | if (apply > standardTotal && standardTotal > 0) { |
| | | warnings.push(`ç³è¯·æ»é¢ ${apply} å
é«äºå·®æ
æ åå计约 ${standardTotal} å
`); |
| | | } |
| | | return warnings; |
| | | } |
| | | |
| | | export function useFinReimburseForm(moduleKeyRef, modeRef) { |
| | | const isTravel = computed( |
| | | () => moduleKeyRef.value === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE |
| | | ); |
| | | |
| | | const form = reactive( |
| | | moduleKeyRef.value === APPROVAL_MODULE_KEYS.COST_REIMBURSE |
| | | ? createEmptyCostForm() |
| | | : createEmptyTravelForm() |
| | | ); |
| | | |
| | | const submitting = ref(false); |
| | | const loading = ref(false); |
| | | const allUsersCache = ref([]); |
| | | const showApplicantPicker = ref(false); |
| | | const applicantDisplaySub = computed(() => { |
| | | if (!form.applicantId) return "ç¹å»éæ©ç³è¯·äºº"; |
| | | const u = userById(form.applicantId); |
| | | if (u) return userSubLabel(u) || form.employeeNo || ""; |
| | | return form.employeeNo ? `å·¥å· ${form.employeeNo}` : ""; |
| | | }); |
| | | const applicantAvatarColor = computed(() => |
| | | userAvatarColor(form.employeeName || form.employeeNo || "") |
| | | ); |
| | | const showCategorySheet = ref(false); |
| | | const showSubjectSheet = ref(false); |
| | | const editingDetailIndex = ref(-1); |
| | | const pickApplicantId = ref(""); |
| | | const pickCategoryValue = ref(""); |
| | | const pickSubjectValue = ref(""); |
| | | |
| | | const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser)); |
| | | |
| | | const travelDaysDisplay = computed(() => { |
| | | if (!isTravel.value) return ""; |
| | | const d = computeTravelDays(form.travelStartTime, form.travelEndTime); |
| | | return d == null ? "" : String(d); |
| | | }); |
| | | |
| | | const travelTierLabel = computed(() => { |
| | | if (!isTravel.value) return ""; |
| | | const std = getTravelStandardByTier(form.travelTier || detectTravelTier(form.destination)); |
| | | return `æ${std.label}æ å`; |
| | | }); |
| | | |
| | | const suggestedLivingSubsidy = computed(() => { |
| | | const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || form.hotelDays || 0; |
| | | const std = getTravelStandardByTier(form.travelTier); |
| | | return Math.round(std.mealPerDay * days * 100) / 100; |
| | | }); |
| | | |
| | | const suggestedTransportSubsidy = computed(() => { |
| | | const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || 0; |
| | | const std = getTravelStandardByTier(form.travelTier); |
| | | return Math.round(std.transportPerDay * days * 100) / 100; |
| | | }); |
| | | |
| | | const suggestedHotelLimit = computed(() => { |
| | | const nights = form.hotelDays || 0; |
| | | const perNight = form.hotelStandard ?? getTravelStandardByTier(form.travelTier).hotelPerNight; |
| | | return Math.round(perNight * nights * 100) / 100; |
| | | }); |
| | | |
| | | const detailTotalAmount = computed(() => { |
| | | const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0); |
| | | return Math.round(sum * 100) / 100; |
| | | }); |
| | | |
| | | const overBudgetWarnings = computed(() => { |
| | | if (!isTravel.value) return []; |
| | | return buildOverBudgetWarnings( |
| | | form, |
| | | detailTotalAmount.value, |
| | | suggestedHotelLimit.value, |
| | | suggestedTransportSubsidy.value, |
| | | suggestedLivingSubsidy.value |
| | | ); |
| | | }); |
| | | |
| | | const expenseSubjectOptions = computed(() => |
| | | isTravel.value ? EXPENSE_SUBJECT_OPTIONS : COST_SUBJECT_OPTIONS |
| | | ); |
| | | |
| | | const categoryActions = computed(() => |
| | | EXPENSE_CATEGORY_OPTIONS.map(x => ({ name: x.label, value: x.value })) |
| | | ); |
| | | |
| | | const categoryLabel = computed(() => { |
| | | const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === form.expenseCategory); |
| | | return hit?.label || "è¯·éæ©è´¹ç¨ç±»å"; |
| | | }); |
| | | |
| | | async function loadUserPool() { |
| | | try { |
| | | allUsersCache.value = unwrapUserList(await userListNoPageByTenantId()); |
| | | } catch { |
| | | allUsersCache.value = []; |
| | | } |
| | | } |
| | | |
| | | function userLabel(u) { |
| | | return userSelectLabel(u); |
| | | } |
| | | |
| | | function userById(id) { |
| | | return allUsersCache.value.find(u => String(u.userId ?? u.id) === String(id)); |
| | | } |
| | | |
| | | function employeeNoFromUser(u) { |
| | | if (!u) return ""; |
| | | return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : ""); |
| | | } |
| | | |
| | | function fillApplicantFromUser(u) { |
| | | if (!u) return; |
| | | form.applicantId = u.userId ?? u.id; |
| | | form.employeeName = u.nickName || u.userName || ""; |
| | | form.employeeNo = employeeNoFromUser(u); |
| | | form.payee = form.payee || form.employeeName; |
| | | form.deptId = String(u.deptId ?? u.sysDeptId ?? ""); |
| | | form.deptName = u.dept?.deptName ?? u.deptName ?? ""; |
| | | } |
| | | |
| | | function onApplicantPicked(uidOrUser) { |
| | | const u = |
| | | typeof uidOrUser === "object" && uidOrUser |
| | | ? uidOrUser |
| | | : userById(uidOrUser); |
| | | fillApplicantFromUser(u); |
| | | } |
| | | |
| | | /** æ°å¢æ¶é»è®¤å¸¦åºå½åç»å½äººï¼åå°é人æ¥éª¤ */ |
| | | function tryApplyCurrentUser() { |
| | | if (modeRef.value === "edit" || form.applicantId) return; |
| | | const id = userStore.id; |
| | | if (!id) return; |
| | | let u = userById(id); |
| | | if (!u) { |
| | | u = { |
| | | userId: id, |
| | | nickName: userStore.nickName, |
| | | userName: userStore.name, |
| | | }; |
| | | } |
| | | fillApplicantFromUser(u); |
| | | } |
| | | |
| | | function recalcTravelStandards() { |
| | | if (!isTravel.value) return; |
| | | form.travelTier = detectTravelTier(form.destination); |
| | | const std = getTravelStandardByTier(form.travelTier); |
| | | if (form.hotelStandard == null || form.hotelStandard === "" || form.hotelStandard === 0) { |
| | | form.hotelStandard = std.hotelPerNight; |
| | | } |
| | | const days = computeTravelDays(form.travelStartTime, form.travelEndTime); |
| | | if (days != null) { |
| | | form.travelDays = days; |
| | | if (form.hotelDays == null || form.hotelDays === "") { |
| | | form.hotelDays = Math.max(0, days - 1); |
| | | } |
| | | if (form.livingSubsidy == null || form.livingSubsidy === "" || form.livingSubsidy === 0) { |
| | | form.livingSubsidy = suggestedLivingSubsidy.value; |
| | | } |
| | | } |
| | | form.needSpecialApproval = overBudgetWarnings.value.length > 0; |
| | | } |
| | | |
| | | function syncApplyAmountFromDetails() { |
| | | form.applyAmount = detailTotalAmount.value; |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function addExpenseDetail() { |
| | | const row = isTravel.value ? createEmptyExpenseDetail() : createCostDetail(); |
| | | form.expenseDetails.push(row); |
| | | } |
| | | |
| | | function removeExpenseDetail(index) { |
| | | form.expenseDetails.splice(index, 1); |
| | | recalcTravelStandards(); |
| | | } |
| | | |
| | | function applyTemplate(category) { |
| | | applyCategoryTemplate(form, category); |
| | | syncApplyAmountFromDetails(); |
| | | } |
| | | |
| | | function resetFormForModule() { |
| | | const empty = isTravel.value ? createEmptyTravelForm() : createEmptyCostForm(); |
| | | Object.keys(form).forEach(k => delete form[k]); |
| | | Object.assign(form, empty); |
| | | if (!form.approvalFlowNodes?.length) { |
| | | form.approvalFlowNodes = [ |
| | | { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }, |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | async function loadEdit(reimbursementId) { |
| | | loading.value = true; |
| | | try { |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | const row = await fetchFinReimbursementFormDetail( |
| | | { reimbursementId }, |
| | | moduleKeyRef.value |
| | | ); |
| | | if (row?.moduleKey && row.moduleKey !== moduleKeyRef.value) { |
| | | moduleKeyRef.value = row.moduleKey; |
| | | } |
| | | Object.assign(form, JSON.parse(JSON.stringify(row)), { |
| | | reimbursementId: row.reimbursementId ?? row.id, |
| | | expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])), |
| | | approvalFlowNodes: JSON.parse( |
| | | JSON.stringify( |
| | | row.approvalFlowNodes?.length |
| | | ? row.approvalFlowNodes |
| | | : [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }] |
| | | ) |
| | | ), |
| | | attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])), |
| | | }); |
| | | if (!isTravel.value && form.expenseCategory) { |
| | | /* å·²ç± mapCost 转为 value */ |
| | | } |
| | | recalcTravelStandards(); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function initForm() { |
| | | resetFormForModule(); |
| | | if (!allUsersCache.value.length) await loadUserPool(); |
| | | if (modeRef.value !== "edit") { |
| | | form.approvalFlowNodes = [ |
| | | { approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }, |
| | | ]; |
| | | tryApplyCurrentUser(); |
| | | } |
| | | } |
| | | |
| | | function validateForm() { |
| | | if (!form.applicantId) { |
| | | uni.showToast({ title: "è¯·éæ©åå·¥", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.reimburseReason || "").trim()) { |
| | | uni.showToast({ title: "è¯·å¡«åæ¥éåå ", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (isTravel.value) { |
| | | if (!form.travelStartTime) { |
| | | uni.showToast({ title: "è¯·éæ©åºå·®å¼å§æ¶é´", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!form.travelEndTime) { |
| | | uni.showToast({ title: "è¯·éæ©åºå·®ç»ææ¶é´", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (computeTravelDays(form.travelStartTime, form.travelEndTime) == null) { |
| | | uni.showToast({ title: "ç»ææ¶é´é¡»æäºå¼å§æ¶é´", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.departurePlace || "").trim()) { |
| | | uni.showToast({ title: "请填ååºå·®å°", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.destination || "").trim()) { |
| | | uni.showToast({ title: "请填åç®çå°", icon: "none" }); |
| | | return false; |
| | | } |
| | | } else if (!form.expenseCategory) { |
| | | uni.showToast({ title: "è¯·éæ©è´¹ç¨ç±»å", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (form.applyAmount === "" || form.applyAmount == null) { |
| | | uni.showToast({ title: "请填åç³è¯·éé¢", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.payee || "").trim()) { |
| | | uni.showToast({ title: "è¯·å¡«åæ¶æ¬¾äºº", icon: "none" }); |
| | | return false; |
| | | } |
| | | if (!(form.expenseDetails || []).length) { |
| | | uni.showToast({ title: "请è³å°æ·»å 䏿¡æ¥éæç»", icon: "none" }); |
| | | return false; |
| | | } |
| | | const nodes = form.approvalFlowNodes || []; |
| | | if (!nodes.length || nodes.some(n => n.approverId == null || n.approverId === "")) { |
| | | uni.showToast({ title: "æ¯ä¸ªå®¡æ¹èç¹é¡»éæ©å®¡æ¹äºº", icon: "none" }); |
| | | return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | async function submitForm() { |
| | | if (!validateForm()) return; |
| | | recalcTravelStandards(); |
| | | if (isTravel.value && form.needSpecialApproval) { |
| | | const ok = await new Promise(resolve => { |
| | | uni.showModal({ |
| | | title: "è¶
æ¯æé", |
| | | content: "åå¨è¶
æ¯é¡¹ï¼æäº¤åå°æ 记为éç¹æ¹ï¼æ¯å¦ç»§ç»ï¼", |
| | | success: r => resolve(!!r.confirm), |
| | | }); |
| | | }); |
| | | if (!ok) return; |
| | | } |
| | | const isEdit = modeRef.value === "edit"; |
| | | const dto = isTravel.value |
| | | ? buildTravelReimbursementSaveDto(form, { computeTravelDays }) |
| | | : buildCostReimbursementSaveDto(form); |
| | | const check = validateReimbursementPersistDto(dto, isEdit); |
| | | if (!check.ok) { |
| | | uni.showToast({ title: check.message, icon: "none" }); |
| | | return; |
| | | } |
| | | submitting.value = true; |
| | | try { |
| | | await persistFinReimbursement(dto, isEdit); |
| | | uni.showToast({ title: isEdit ? "ä¿åæå" : "æäº¤æå", icon: "success" }); |
| | | return true; |
| | | } catch { |
| | | uni.showToast({ title: isEdit ? "ä¿å失败" : "æäº¤å¤±è´¥", icon: "none" }); |
| | | return false; |
| | | } finally { |
| | | submitting.value = false; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | form, |
| | | isTravel, |
| | | submitting, |
| | | loading, |
| | | flowUserOptions, |
| | | travelDaysDisplay, |
| | | travelTierLabel, |
| | | suggestedLivingSubsidy, |
| | | suggestedTransportSubsidy, |
| | | suggestedHotelLimit, |
| | | detailTotalAmount, |
| | | overBudgetWarnings, |
| | | expenseSubjectOptions, |
| | | categoryActions, |
| | | categoryLabel, |
| | | showApplicantPicker, |
| | | applicantDisplaySub, |
| | | applicantAvatarColor, |
| | | showCategorySheet, |
| | | showSubjectSheet, |
| | | editingDetailIndex, |
| | | pickCategoryValue, |
| | | pickSubjectValue, |
| | | loadUserPool, |
| | | userLabel, |
| | | onApplicantPicked, |
| | | tryApplyCurrentUser, |
| | | recalcTravelStandards, |
| | | syncApplyAmountFromDetails, |
| | | addExpenseDetail, |
| | | removeExpenseDetail, |
| | | applyTemplate, |
| | | initForm, |
| | | loadEdit, |
| | | submitForm, |
| | | getReimbursementTypeByModuleKey, |
| | | }; |
| | | } |
| | |
| | | <!-- |
| | | OA / æ¥é管ç / å·®æ
æ¥é |
| | | è·¯ç±ï¼/pages/oa/ReimburseManage/travel-reimburse/index |
| | | OA / æ¥é管ç / å·®æ
æ¥éï¼/finReimbursement/listPageï¼reimbursementType=1ï¼ |
| | | --> |
| | | <template> |
| | | <OaListPage v-if="config" |
| | | :page-key="pageKey" |
| | | :page-config="config" /> |
| | | <FinReimbursementListPage :module-key="APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE" /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | /** OA - æ¥é管ç - å·®æ
æ¥é */ |
| | | import OaListPage from "../../_components/OaListPage.vue"; |
| | | import { useOaPage } from "../../_utils/useOaPage.js"; |
| | | |
| | | const pageKey = "ReimburseManage/travel-reimburse"; |
| | | const { config } = useOaPage(pageKey); |
| | | import FinReimbursementListPage from "../../_components/FinReimbursementListPage.vue"; |
| | | import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js"; |
| | | </script> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | å·®æ
/è´¹ç¨æ¥éå表ï¼/finReimbursement/listPageï¼ |
| | | --> |
| | | <template> |
| | | <view class="oa-approval-page"> |
| | | <PageHeader :title="pageTitle" |
| | | @back="goBack" /> |
| | | |
| | | <view class="oa-toolbar"> |
| | | <view class="oa-filter-chip" |
| | | :class="{ active: hasActiveFilter }" |
| | | @click="showFilter = true"> |
| | | <up-icon name="list" |
| | | size="18" |
| | | :color="hasActiveFilter ? '#2979ff' : '#666'" /> |
| | | <text class="chip-label">çé</text> |
| | | <text v-if="filterSummary" |
| | | class="chip-value">{{ filterSummary }}</text> |
| | | <text v-else |
| | | class="chip-placeholder">å
¨é¨æ¡ä»¶</text> |
| | | </view> |
| | | <view class="oa-icon-btn" |
| | | @click="handleSearch"> |
| | | <up-icon name="search" |
| | | size="20" |
| | | color="#666" /> |
| | | </view> |
| | | </view> |
| | | |
| | | <ApprovalModuleSearchPopup v-model:show="showFilter" |
| | | :module-key="moduleKey" |
| | | v-model="searchForm" |
| | | @search="handleSearch" |
| | | @reset="handleReset" /> |
| | | |
| | | <scroll-view class="oa-list-scroll" |
| | | scroll-y |
| | | :show-scrollbar="false" |
| | | :style="{ height: listScrollHeight + 'px' }" |
| | | @scrolltolower="loadMore"> |
| | | <view v-if="displayList.length" |
| | | class="oa-card-list"> |
| | | <view v-for="item in displayList" |
| | | :key="item.reimbursementId || item.id" |
| | | class="oa-card"> |
| | | <view class="oa-card-head"> |
| | | <view class="oa-card-title-wrap"> |
| | | <text class="oa-card-title">{{ cardTitle(item) }}</text> |
| | | <text v-if="item.billNo" |
| | | class="oa-card-sub">{{ item.billNo }}</text> |
| | | </view> |
| | | <text :class="['oa-status', billStatusCssClass(item)]"> |
| | | {{ billStatusLabel(item.billStatus ?? item.status) }} |
| | | </text> |
| | | </view> |
| | | |
| | | <view class="oa-card-body"> |
| | | <view class="oa-info-grid"> |
| | | <view v-for="(row, idx) in visibleDisplayRows(item)" |
| | | :key="'f-' + idx" |
| | | class="oa-info-row"> |
| | | <text class="oa-info-label">{{ row.label }}</text> |
| | | <text class="oa-info-value">{{ row.value || "-" }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·äºº</text> |
| | | <text class="oa-info-value">{{ item.applicantName || "-" }}</text> |
| | | </view> |
| | | <view class="oa-info-row"> |
| | | <text class="oa-info-label">ç³è¯·æ¶é´</text> |
| | | <text class="oa-info-value">{{ formatListTime(item.createTime) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="oa-card-foot" |
| | | @click.stop> |
| | | <text class="oa-foot-btn btn-detail" |
| | | @click="openDetail(item)">详æ
</text> |
| | | <text v-if="canEditReimbursementRow(item)" |
| | | class="oa-foot-btn btn-edit" |
| | | @click="goEdit(item)">ä¿®æ¹</text> |
| | | <text v-if="canDeleteReimbursementRow(item)" |
| | | class="oa-foot-btn btn-delete" |
| | | @click="confirmDelete(item)">å é¤</text> |
| | | </view> |
| | | </view> |
| | | <up-loadmore :status="pageStatus" /> |
| | | </view> |
| | | |
| | | <view v-else-if="!tableLoading" |
| | | class="oa-empty"> |
| | | <up-empty mode="list" |
| | | :text="`ææ ${pageTitle}æ°æ®`" /> |
| | | </view> |
| | | <view v-if="tableLoading && !list.length" |
| | | class="oa-loading"> |
| | | <up-loading-icon mode="circle" /> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <view class="fab-button" |
| | | @click="handleAdd"> |
| | | <up-icon name="plus" |
| | | size="28" |
| | | color="#ffffff" /> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, onMounted, reactive, ref } from "vue"; |
| | | import { onShow } from "@dcloudio/uni-app"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import ApprovalModuleSearchPopup from "./ApprovalModuleSearchPopup.vue"; |
| | | import { listFinReimbursementPage } from "@/api/oa/finReimbursement.js"; |
| | | import { OA_NAV } from "@/config/oaPaths.js"; |
| | | import { getApprovalModuleConfig } from "../_utils/approvalModuleRegistry.js"; |
| | | import { |
| | | createModuleSearchForm, |
| | | filterRowsByModuleSearch, |
| | | formatDateRangeLabel, |
| | | getModuleSearchMeta, |
| | | } from "../_utils/approvalModuleListSearch.js"; |
| | | import { parseTime } from "@/utils/ruoyi"; |
| | | import { |
| | | billStatusCssClass, |
| | | billStatusLabel, |
| | | buildFinReimbursementListParams, |
| | | canDeleteReimbursementRow, |
| | | canEditReimbursementRow, |
| | | deleteFinReimbursement, |
| | | getReimbursementTypeByModuleKey, |
| | | filterRowsByReimbursementType, |
| | | mapFinReimbursementFromApi, |
| | | resolveReimbursementDeleteId, |
| | | unwrapFinReimbursementPage, |
| | | } from "../_utils/finReimbursementMappers.js"; |
| | | |
| | | const props = defineProps({ |
| | | moduleKey: { type: String, required: true }, |
| | | }); |
| | | |
| | | const moduleConfig = computed(() => getApprovalModuleConfig(props.moduleKey)); |
| | | const pageTitle = computed(() => moduleConfig.value?.label || "æ¥é"); |
| | | const reimbursementType = computed(() => |
| | | getReimbursementTypeByModuleKey(props.moduleKey) |
| | | ); |
| | | |
| | | const showFilter = ref(false); |
| | | const searchForm = reactive(createModuleSearchForm(props.moduleKey)); |
| | | const list = ref([]); |
| | | const tableLoading = ref(false); |
| | | const pageStatus = ref("loadmore"); |
| | | |
| | | const page = reactive({ current: 1, size: 10, total: 0 }); |
| | | const listScrollHeight = ref(400); |
| | | |
| | | function calcListScrollHeight() { |
| | | const sys = uni.getSystemInfoSync(); |
| | | const statusBar = sys.statusBarHeight || 0; |
| | | const navBar = 44; |
| | | const toolbar = 56; |
| | | const fabGap = 16; |
| | | listScrollHeight.value = Math.max( |
| | | 200, |
| | | sys.windowHeight - statusBar - navBar - toolbar - fabGap |
| | | ); |
| | | } |
| | | |
| | | const displayList = computed(() => |
| | | filterRowsByModuleSearch(props.moduleKey, list.value, searchForm) |
| | | ); |
| | | |
| | | const hasActiveFilter = computed(() => Boolean(filterSummary.value)); |
| | | |
| | | const filterSummary = computed(() => { |
| | | const parts = []; |
| | | const meta = getModuleSearchMeta(props.moduleKey); |
| | | for (const field of meta.fields || []) { |
| | | const val = searchForm[field.key]; |
| | | if (field.type === "input" && (val || "").trim()) { |
| | | parts.push(`${field.label}:${String(val).trim()}`); |
| | | } else if (field.type === "daterange" && Array.isArray(val) && val[0]) { |
| | | parts.push(`${field.label}:${formatDateRangeLabel(val)}`); |
| | | } else if (field.type === "select" && val) { |
| | | const opt = (field.options || []).find(o => o.value === val); |
| | | parts.push(`${field.label}:${opt?.label || val}`); |
| | | } |
| | | } |
| | | return parts.join("ï¼"); |
| | | }); |
| | | |
| | | function cardTitle(item) { |
| | | return item.summary || item.title || item.reason || pageTitle.value; |
| | | } |
| | | |
| | | function visibleDisplayRows(item) { |
| | | return (item.displayRows || []).slice(0, 3); |
| | | } |
| | | |
| | | function formatListTime(t) { |
| | | if (!t) return "-"; |
| | | const formatted = parseTime(t, "{y}-{m}-{d} {h}:{i}"); |
| | | return formatted || String(t).replace("T", " ").slice(0, 16); |
| | | } |
| | | |
| | | const fetchList = async (reset = false) => { |
| | | if (!reimbursementType.value) return; |
| | | |
| | | if (reset) { |
| | | page.current = 1; |
| | | pageStatus.value = "loadmore"; |
| | | list.value = []; |
| | | } |
| | | if (pageStatus.value === "loading" || pageStatus.value === "nomore") return; |
| | | |
| | | pageStatus.value = "loading"; |
| | | tableLoading.value = true; |
| | | |
| | | try { |
| | | const res = await listFinReimbursementPage( |
| | | buildFinReimbursementListParams({ |
| | | page, |
| | | searchForm, |
| | | reimbursementType: reimbursementType.value, |
| | | }) |
| | | ); |
| | | const { records, total } = unwrapFinReimbursementPage(res); |
| | | const mapped = filterRowsByReimbursementType( |
| | | records, |
| | | reimbursementType.value |
| | | ).map(row => |
| | | mapFinReimbursementFromApi(row, { |
| | | reimbursementType: reimbursementType.value, |
| | | moduleKey: props.moduleKey, |
| | | }) |
| | | ); |
| | | |
| | | if (page.current === 1) { |
| | | list.value = mapped; |
| | | } else { |
| | | list.value = [...list.value, ...mapped]; |
| | | } |
| | | page.total = total; |
| | | |
| | | if (list.value.length >= total || records.length < page.size) { |
| | | pageStatus.value = "nomore"; |
| | | } else { |
| | | pageStatus.value = "loadmore"; |
| | | page.current += 1; |
| | | } |
| | | } catch { |
| | | if (page.current === 1) list.value = []; |
| | | pageStatus.value = "loadmore"; |
| | | uni.showToast({ title: `${pageTitle.value}å 载失败`, icon: "none" }); |
| | | } finally { |
| | | tableLoading.value = false; |
| | | } |
| | | }; |
| | | |
| | | const handleSearch = () => fetchList(true); |
| | | const handleReset = () => { |
| | | Object.assign(searchForm, createModuleSearchForm(props.moduleKey)); |
| | | fetchList(true); |
| | | }; |
| | | const loadMore = () => { |
| | | if (pageStatus.value === "loadmore") fetchList(false); |
| | | }; |
| | | const goBack = () => uni.navigateBack(); |
| | | |
| | | const openDetail = item => { |
| | | const rid = resolveReimbursementDeleteId(item); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³æ¥ç详æ
ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseDetail}?moduleKey=${props.moduleKey}&reimbursementId=${rid}`, |
| | | }); |
| | | }; |
| | | |
| | | const handleAdd = () => { |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${props.moduleKey}&mode=add`, |
| | | }); |
| | | }; |
| | | |
| | | const goEdit = item => { |
| | | if (!canEditReimbursementRow(item)) { |
| | | uni.showToast({ title: "审æ¹ä¸æå·²å®æçæ¥éä¸å¯ä¿®æ¹", icon: "none" }); |
| | | return; |
| | | } |
| | | const rid = resolveReimbursementDeleteId(item); |
| | | if (rid == null) { |
| | | uni.showToast({ title: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.navigateTo({ |
| | | url: `${OA_NAV.reimburseForm}?moduleKey=${props.moduleKey}&mode=edit&reimbursementId=${rid}`, |
| | | }); |
| | | }; |
| | | |
| | | const confirmDelete = item => { |
| | | if (!canDeleteReimbursementRow(item)) { |
| | | uni.showToast({ title: "è¯¥ç¶æä¸å¯å é¤", icon: "none" }); |
| | | return; |
| | | } |
| | | const id = resolveReimbursementDeleteId(item); |
| | | if (id == null) { |
| | | uni.showToast({ title: "æ æ³å é¤ï¼ç¼ºå°æ¥éå ID", icon: "none" }); |
| | | return; |
| | | } |
| | | const title = item.billNo || item.summary || item.title || "该æ¥éå"; |
| | | uni.showModal({ |
| | | title: "å é¤ç¡®è®¤", |
| | | content: `ç¡®å®è¦å é¤ã${title}ãåï¼å é¤åä¸å¯æ¢å¤ã`, |
| | | confirmText: "ç¡®å®å é¤", |
| | | confirmColor: "#f56c6c", |
| | | success: async res => { |
| | | if (!res.confirm) return; |
| | | try { |
| | | await deleteFinReimbursement([id]); |
| | | uni.showToast({ title: "å 餿å", icon: "success" }); |
| | | fetchList(true); |
| | | } catch { |
| | | uni.showToast({ title: "å é¤å¤±è´¥", icon: "none" }); |
| | | } |
| | | }, |
| | | }); |
| | | }; |
| | | |
| | | onMounted(() => { |
| | | calcListScrollHeight(); |
| | | }); |
| | | |
| | | onShow(() => { |
| | | calcListScrollHeight(); |
| | | fetchList(true); |
| | | }); |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | @import "@/styles/sales-common.scss"; |
| | | @import "../_styles/oa-approval-list.scss"; |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- |
| | | OA éç¨ï¼å¯æç´¢çç¨æ·åéå¼¹å±ï¼ç¹éå³ç¡®è®¤ï¼ |
| | | --> |
| | | <template> |
| | | <up-popup :show="show" |
| | | mode="bottom" |
| | | round="16" |
| | | :safe-area-inset-bottom="true" |
| | | @close="emit('update:show', false)"> |
| | | <view class="oa-user-sheet"> |
| | | <view class="sheet-handle" /> |
| | | <view class="sheet-head"> |
| | | <text class="sheet-cancel" |
| | | @click="emit('update:show', false)">åæ¶</text> |
| | | <text class="sheet-title">{{ title }}</text> |
| | | <text class="sheet-spacer" /> |
| | | </view> |
| | | |
| | | <view class="sheet-search"> |
| | | <up-search v-model="keyword" |
| | | placeholder="æç´¢å§åæå·¥å·" |
| | | :show-action="false" |
| | | shape="round" |
| | | bg-color="#f5f7fa" /> |
| | | </view> |
| | | |
| | | <view v-if="selfUser && showSelfQuick" |
| | | class="self-quick" |
| | | @click="pickUser(selfUser)"> |
| | | <view class="user-avatar" |
| | | :style="{ backgroundColor: avatarColor(selfUser.nickName || selfUser.userName) }"> |
| | | {{ (selfUser.nickName || selfUser.userName || "æ").charAt(0) }} |
| | | </view> |
| | | <view class="user-meta"> |
| | | <text class="user-name">éæ¬äºº · {{ userSelectLabel(selfUser) }}</text> |
| | | <text class="user-sub">{{ userSubLabel(selfUser) }}</text> |
| | | </view> |
| | | <up-icon name="arrow-right" |
| | | size="14" |
| | | color="#c0c4cc" /> |
| | | </view> |
| | | |
| | | <scroll-view scroll-y |
| | | class="user-scroll" |
| | | :show-scrollbar="false"> |
| | | <view v-for="u in filteredList" |
| | | :key="String(u.userId ?? u.id)" |
| | | class="user-item" |
| | | :class="{ selected: isSelected(u) }" |
| | | @click="pickUser(u)"> |
| | | <view class="user-avatar" |
| | | :style="{ backgroundColor: avatarColor(u.nickName || u.userName) }"> |
| | | {{ (u.nickName || u.userName || "?").charAt(0) }} |
| | | </view> |
| | | <view class="user-meta"> |
| | | <text class="user-name">{{ userSelectLabel(u) }}</text> |
| | | <text class="user-sub">{{ userSubLabel(u) }}</text> |
| | | </view> |
| | | <view class="user-check" |
| | | :class="{ checked: isSelected(u) }"> |
| | | <up-icon v-if="isSelected(u)" |
| | | name="checkmark" |
| | | size="14" |
| | | color="#fff" /> |
| | | </view> |
| | | </view> |
| | | <view v-if="!filteredList.length" |
| | | class="user-empty"> |
| | | <up-empty mode="search" |
| | | text="ææ å¹é
ç¨æ·" /> |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | | </up-popup> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref, watch } from "vue"; |
| | | import useUserStore from "@/store/modules/user"; |
| | | import { |
| | | filterActiveUsers, |
| | | userAvatarColor, |
| | | userSelectLabel, |
| | | userSubLabel, |
| | | } from "../_utils/userPickerUtils.js"; |
| | | |
| | | const props = defineProps({ |
| | | show: { type: Boolean, default: false }, |
| | | title: { type: String, default: "éæ©åå·¥" }, |
| | | users: { type: Array, default: () => [] }, |
| | | modelValue: { type: [String, Number], default: "" }, |
| | | showSelfQuick: { type: Boolean, default: true }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:show", "update:modelValue", "select"]); |
| | | |
| | | const keyword = ref(""); |
| | | const userStore = useUserStore(); |
| | | |
| | | const filteredList = computed(() => |
| | | filterActiveUsers(props.users, keyword.value, 100) |
| | | ); |
| | | |
| | | const selfUser = computed(() => { |
| | | const id = userStore.id; |
| | | if (!id) return null; |
| | | const hit = props.users.find(u => String(u.userId ?? u.id) === String(id)); |
| | | if (hit) return hit; |
| | | return { |
| | | userId: id, |
| | | nickName: userStore.nickName, |
| | | userName: userStore.name, |
| | | }; |
| | | }); |
| | | |
| | | watch( |
| | | () => props.show, |
| | | v => { |
| | | if (v) keyword.value = ""; |
| | | } |
| | | ); |
| | | |
| | | function avatarColor(name) { |
| | | return userAvatarColor(name); |
| | | } |
| | | |
| | | function isSelected(u) { |
| | | const id = u.userId ?? u.id; |
| | | return id != null && String(id) === String(props.modelValue ?? ""); |
| | | } |
| | | |
| | | function pickUser(u) { |
| | | const id = u.userId ?? u.id; |
| | | emit("update:modelValue", id); |
| | | emit("select", u); |
| | | emit("update:show", false); |
| | | } |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .oa-user-sheet { |
| | | background: #fff; |
| | | border-radius: 16px 16px 0 0; |
| | | max-height: 78vh; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | .sheet-handle { |
| | | width: 36px; |
| | | height: 4px; |
| | | background: #e4e7ed; |
| | | border-radius: 2px; |
| | | margin: 8px auto 4px; |
| | | } |
| | | .sheet-head { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 8px 16px 12px; |
| | | } |
| | | .sheet-cancel { |
| | | font-size: 15px; |
| | | color: #909399; |
| | | min-width: 48px; |
| | | } |
| | | .sheet-title { |
| | | flex: 1; |
| | | text-align: center; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | } |
| | | .sheet-spacer { |
| | | min-width: 48px; |
| | | } |
| | | .sheet-search { |
| | | padding: 0 16px 10px; |
| | | } |
| | | .self-quick { |
| | | display: flex; |
| | | align-items: center; |
| | | margin: 0 16px 8px; |
| | | padding: 12px; |
| | | background: linear-gradient(135deg, #ecf5ff 0%, #f0f9ff 100%); |
| | | border-radius: 12px; |
| | | border: 1px solid #d9ecff; |
| | | } |
| | | .user-scroll { |
| | | flex: 1; |
| | | max-height: 52vh; |
| | | padding: 0 8px 16px; |
| | | box-sizing: border-box; |
| | | } |
| | | .user-item, |
| | | .self-quick { |
| | | &:active { |
| | | opacity: 0.85; |
| | | } |
| | | } |
| | | .user-item { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 12px 10px; |
| | | border-radius: 10px; |
| | | margin-bottom: 4px; |
| | | &.selected { |
| | | background: #f0f7ff; |
| | | } |
| | | } |
| | | .user-avatar { |
| | | width: 40px; |
| | | height: 40px; |
| | | border-radius: 50%; |
| | | color: #fff; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | } |
| | | .user-meta { |
| | | flex: 1; |
| | | margin-left: 12px; |
| | | min-width: 0; |
| | | } |
| | | .user-name { |
| | | display: block; |
| | | font-size: 15px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .user-sub { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-top: 2px; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | .user-check { |
| | | width: 22px; |
| | | height: 22px; |
| | | border-radius: 50%; |
| | | border: 2px solid #dcdfe6; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-shrink: 0; |
| | | &.checked { |
| | | background: #2979ff; |
| | | border-color: #2979ff; |
| | | } |
| | | } |
| | | .user-empty { |
| | | padding: 24px 0; |
| | | } |
| | | </style> |
| | |
| | | border-radius: 16px; |
| | | text-align: center; |
| | | |
| | | &.btn-detail { |
| | | color: #fff; |
| | | background: #2979ff; |
| | | } |
| | | |
| | | &.btn-edit { |
| | | color: #2979ff; |
| | | background: #ecf3ff; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import dayjs from "dayjs"; |
| | | import { |
| | | deleteFinReimbursement, |
| | | getFinReimbursementDetail, |
| | | persistFinReimbursement, |
| | | } from "@/api/oa/finReimbursement.js"; |
| | | import { APPROVAL_MODULE_KEYS } from "./approvalModuleRegistry.js"; |
| | | import { businessStatusClass, normalizeApprovalStatusKey } from "./approveListUtils.js"; |
| | | import { |
| | | EXPENSE_CATEGORY_OPTIONS, |
| | | expenseTypeToCategory, |
| | | } from "../ReimburseManage/_utils/costReimburseUtils.js"; |
| | | import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/travelReimburseUtils.js"; |
| | | import { EXPENSE_SUBJECT_OPTIONS as COST_EXPENSE_SUBJECTS } from "../ReimburseManage/_utils/costReimburseUtils.js"; |
| | | import { resolveExpenseSubjectLabel } from "../ReimburseManage/_utils/expenseDetailDisplay.js"; |
| | | import { applyFinReimbursementDetailEnrichment } from "../ReimburseManage/_utils/finReimbursementDetailExtras.js"; |
| | | |
| | | export const FIN_REIMBURSEMENT_TYPE = { |
| | | TRAVEL: "1", |
| | | COST: "2", |
| | | }; |
| | | |
| | | const REIMBURSEMENT_TYPE_LABEL = { |
| | | [FIN_REIMBURSEMENT_TYPE.TRAVEL]: "å·®æ
æ¥é", |
| | | [FIN_REIMBURSEMENT_TYPE.COST]: "è´¹ç¨æ¥é", |
| | | }; |
| | | |
| | | /** å½ä¸åæ¥éç±»åï¼1-å·®æ
ï¼2-è´¹ç¨ */ |
| | | export function normalizeReimbursementType(val) { |
| | | const s = String(val ?? "").trim(); |
| | | if (s === "1" || s === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | return FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | } |
| | | if (s === "2" || s === FIN_REIMBURSEMENT_TYPE.COST) { |
| | | return FIN_REIMBURSEMENT_TYPE.COST; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | export function reimbursementTypeLabel(type) { |
| | | return REIMBURSEMENT_TYPE_LABEL[normalizeReimbursementType(type)] || "â"; |
| | | } |
| | | |
| | | export function getModuleKeyByReimbursementType(type) { |
| | | const t = normalizeReimbursementType(type); |
| | | if (t === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | return APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE; |
| | | } |
| | | if (t === FIN_REIMBURSEMENT_TYPE.COST) { |
| | | return APPROVAL_MODULE_KEYS.COST_REIMBURSE; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** ä¼å
æ¥å£ reimbursementTypeï¼å
¶æ¬¡é¡µé¢ moduleKey / å
¥å */ |
| | | export function resolveReimbursementType(raw, fallback) { |
| | | const fromApi = normalizeReimbursementType(raw?.reimbursementType); |
| | | if (fromApi) return fromApi; |
| | | return ( |
| | | normalizeReimbursementType(fallback) || |
| | | getReimbursementTypeByModuleKey(fallback) || |
| | | "" |
| | | ); |
| | | } |
| | | |
| | | export function isTravelReimbursementType(type) { |
| | | return resolveReimbursementType({ reimbursementType: type }, type) === FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | } |
| | | |
| | | export function filterRowsByReimbursementType(rows, expectedType) { |
| | | const expected = normalizeReimbursementType(expectedType); |
| | | if (!expected) return rows || []; |
| | | return (rows || []).filter(row => { |
| | | const t = resolveReimbursementType(row, expected); |
| | | return t === expected; |
| | | }); |
| | | } |
| | | |
| | | const BILL_STATUS_LABEL = { |
| | | DRAFT: "è稿", |
| | | IN_APPROVAL: "审æ¹ä¸", |
| | | APPROVED: "审æ¹éè¿", |
| | | REJECTED: "审æ¹é©³å", |
| | | WITHDRAWN: "å·²æ¤å", |
| | | PAID: "已仿¬¾", |
| | | }; |
| | | |
| | | export function getReimbursementTypeByModuleKey(moduleKey) { |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE) { |
| | | return FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | } |
| | | if (moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE) { |
| | | return FIN_REIMBURSEMENT_TYPE.COST; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | export function unwrapFinReimbursementPage(res) { |
| | | const data = res?.data ?? res; |
| | | if (!data || typeof data !== "object") { |
| | | return { records: [], total: 0 }; |
| | | } |
| | | if (Array.isArray(data.records)) { |
| | | return { records: data.records, total: Number(data.total ?? 0) }; |
| | | } |
| | | const nested = data.data; |
| | | if (nested && typeof nested === "object" && Array.isArray(nested.records)) { |
| | | return { records: nested.records, total: Number(nested.total ?? 0) }; |
| | | } |
| | | return { records: [], total: 0 }; |
| | | } |
| | | |
| | | /** 详æ
æ¥å£ data è§£å
*/ |
| | | export function unwrapFinReimbursementDetail(res) { |
| | | const data = res?.data ?? res; |
| | | if (!data || typeof data !== "object") return {}; |
| | | if (data.billNo != null || data.id != null || data.reimbursementType != null) { |
| | | return data; |
| | | } |
| | | const nested = data.data; |
| | | if (nested && typeof nested === "object" && !Array.isArray(nested)) { |
| | | return nested; |
| | | } |
| | | if (data.finReimbursementDto && typeof data.finReimbursementDto === "object") { |
| | | return data.finReimbursementDto; |
| | | } |
| | | return data; |
| | | } |
| | | |
| | | export function mapBillStatusToApprovalKey(billStatus) { |
| | | const upper = String(billStatus ?? "").trim().toUpperCase(); |
| | | if (upper === "DRAFT") return "draft"; |
| | | if (upper === "IN_APPROVAL") return "pending"; |
| | | if (upper === "APPROVED") return "approved"; |
| | | if (upper === "REJECTED") return "rejected"; |
| | | if (upper === "WITHDRAWN") return "cancelled"; |
| | | if (upper === "PAID") return "approved"; |
| | | return normalizeApprovalStatusKey(billStatus); |
| | | } |
| | | |
| | | export function billStatusLabel(billStatus) { |
| | | const upper = String(billStatus ?? "").trim().toUpperCase(); |
| | | if (BILL_STATUS_LABEL[upper]) return BILL_STATUS_LABEL[upper]; |
| | | const key = mapBillStatusToApprovalKey(billStatus); |
| | | if (key === "draft") return "è稿"; |
| | | if (key === "approved") return "已宿"; |
| | | if (key === "rejected") return "已驳å"; |
| | | if (key === "cancelled") return "å·²æ¤å"; |
| | | return "è¿è¡ä¸"; |
| | | } |
| | | |
| | | export function billStatusCssClass(item) { |
| | | return businessStatusClass( |
| | | mapBillStatusToApprovalKey(item?.billStatus ?? item?.status) |
| | | ); |
| | | } |
| | | |
| | | function pickApplicantQuery(searchForm = {}) { |
| | | const kw = (searchForm.applicantKeyword || "").trim(); |
| | | if (!kw) return {}; |
| | | if (/[\u4e00-\u9fa5]/.test(kw)) return { applicantName: kw }; |
| | | return { applicantCode: kw }; |
| | | } |
| | | |
| | | export function buildFinReimbursementListParams({ |
| | | page, |
| | | searchForm, |
| | | reimbursementType, |
| | | extraDto = {}, |
| | | }) { |
| | | const dto = { |
| | | reimbursementType, |
| | | ...pickApplicantQuery(searchForm), |
| | | ...(extraDto && typeof extraDto === "object" ? extraDto : {}), |
| | | }; |
| | | |
| | | const range = searchForm?.createTimeRange ?? searchForm?.applyDateRange; |
| | | if (Array.isArray(range) && range[0]) { |
| | | dto.createTimeStart = range[0]; |
| | | } |
| | | if (Array.isArray(range) && range[1]) { |
| | | dto.createTimeEnd = range[1]; |
| | | } |
| | | |
| | | if (reimbursementType === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | if (searchForm?.travelStartFrom) { |
| | | dto.startTimeStart = searchForm.travelStartFrom; |
| | | } |
| | | if (searchForm?.travelEndTo) { |
| | | dto.endTimeEnd = searchForm.travelEndTo; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | page: { |
| | | current: page.current, |
| | | size: page.size, |
| | | }, |
| | | finReimbursementDto: dto, |
| | | }; |
| | | } |
| | | |
| | | function pickTravelField(obj, keys) { |
| | | if (!obj || typeof obj !== "object") return ""; |
| | | for (const key of keys) { |
| | | const v = obj[key]; |
| | | if (v != null && v !== "") return v; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** å
¼å®¹ list/detail å¤ç§å·®æ
åå¯¹è±¡ç»æ */ |
| | | export function pickTravelFromRow(row) { |
| | | if (!row || typeof row !== "object") return {}; |
| | | const nested = |
| | | (row.travel && typeof row.travel === "object" ? row.travel : null) || |
| | | row.finReimbursementTravel || |
| | | row.finReimbursementTravelDto || |
| | | row.travelDto || |
| | | row.travelVO || |
| | | {}; |
| | | const src = |
| | | nested && typeof nested === "object" && Object.keys(nested).length |
| | | ? nested |
| | | : row; |
| | | return { |
| | | startTime: pickTravelField(src, [ |
| | | "startTime", |
| | | "travelStartTime", |
| | | "startDate", |
| | | "travelStartDate", |
| | | "departureTime", |
| | | ]), |
| | | endTime: pickTravelField(src, [ |
| | | "endTime", |
| | | "travelEndTime", |
| | | "endDate", |
| | | "travelEndDate", |
| | | "returnTime", |
| | | ]), |
| | | travelDays: src.travelDays, |
| | | departureCity: pickTravelField(src, [ |
| | | "departureCity", |
| | | "departurePlace", |
| | | "departure", |
| | | ]), |
| | | destinationCity: pickTravelField(src, [ |
| | | "destinationCity", |
| | | "destination", |
| | | "destinationPlace", |
| | | ]), |
| | | hotelStandard: src.hotelStandard, |
| | | lodgingDays: src.lodgingDays ?? src.hotelDays, |
| | | mealAllowance: src.mealAllowance ?? src.livingSubsidy, |
| | | transportAllowance: src.transportAllowance ?? src.transportSubsidy, |
| | | lodgingLimit: src.lodgingLimit, |
| | | withinStandard: src.withinStandard, |
| | | standardTag: src.standardTag || "", |
| | | id: src.id, |
| | | reimbursementId: src.reimbursementId, |
| | | }; |
| | | } |
| | | |
| | | export function formatReimbursementDateTime(val) { |
| | | if (val == null || val === "") return ""; |
| | | const d = dayjs(val); |
| | | if (!d.isValid()) return String(val); |
| | | const raw = String(val); |
| | | const hasTime = raw.includes("T") || /:\d{2}/.test(raw); |
| | | return hasTime ? d.format("YYYY-MM-DD HH:mm:ss") : d.format("YYYY-MM-DD"); |
| | | } |
| | | |
| | | export function mapFinReimbursementFromApi(row, { reimbursementType, moduleKey } = {}) { |
| | | if (!row) return {}; |
| | | const type = resolveReimbursementType( |
| | | row, |
| | | reimbursementType || getReimbursementTypeByModuleKey(moduleKey) |
| | | ); |
| | | const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | const travel = isTravel ? pickTravelFromRow(row) : {}; |
| | | const instanceId = row.approvalInstanceId ?? row.id; |
| | | |
| | | return { |
| | | ...row, |
| | | reimbursementId: row.id, |
| | | id: instanceId, |
| | | approvalInstanceId: row.approvalInstanceId, |
| | | instanceNo: row.billNo || "", |
| | | billNo: row.billNo || "", |
| | | reimbursementType: type, |
| | | reimbursementTypeLabel: reimbursementTypeLabel(type), |
| | | moduleKey: getModuleKeyByReimbursementType(type), |
| | | applicantNo: row.applicantCode || "", |
| | | applicantCode: row.applicantCode || "", |
| | | applicantName: row.applicantName || "", |
| | | reason: row.reason || "", |
| | | expenseType: row.expenseType || "", |
| | | applyAmount: row.applyAmount, |
| | | billStatus: row.billStatus, |
| | | status: row.billStatus, |
| | | approvalStatus: mapBillStatusToApprovalKey(row.billStatus), |
| | | title: row.reason || row.billNo || "", |
| | | summary: row.reason || row.billNo || "", |
| | | createTime: formatReimbursementDateTime(row.createTime), |
| | | departurePlace: travel.departureCity || "", |
| | | destination: travel.destinationCity || "", |
| | | travelStartTime: formatReimbursementDateTime(travel.startTime), |
| | | travelEndTime: formatReimbursementDateTime(travel.endTime), |
| | | travel, |
| | | details: row.details || [], |
| | | nodes: row.nodes || [], |
| | | flowNodes: row.nodes || [], |
| | | displayRows: buildFinReimbursementDisplayRows( |
| | | { |
| | | billNo: row.billNo, |
| | | applyAmount: row.applyAmount, |
| | | billStatus: row.billStatus, |
| | | departurePlace: travel.departureCity, |
| | | destination: travel.destinationCity, |
| | | expenseType: row.expenseType, |
| | | reason: row.reason, |
| | | }, |
| | | type |
| | | ), |
| | | }; |
| | | } |
| | | |
| | | export function buildFinReimbursementDisplayRows(item, reimbursementType) { |
| | | const type = normalizeReimbursementType(reimbursementType); |
| | | const isTravel = type === FIN_REIMBURSEMENT_TYPE.TRAVEL; |
| | | const rows = [ |
| | | { label: "æ¥éåå·", value: item.billNo }, |
| | | { |
| | | label: "ç³è¯·éé¢", |
| | | value: item.applyAmount != null ? `${item.applyAmount} å
` : "", |
| | | }, |
| | | { label: "åæ®ç¶æ", value: billStatusLabel(item.billStatus) }, |
| | | ]; |
| | | if (isTravel) { |
| | | rows.splice( |
| | | 1, |
| | | 0, |
| | | { label: "åºå·®å°", value: item.departurePlace }, |
| | | { label: "ç®çå°", value: item.destination } |
| | | ); |
| | | } else { |
| | | rows.splice(1, 0, { label: "è´¹ç¨ç±»å", value: item.expenseType }); |
| | | } |
| | | if (item.reason) { |
| | | rows.push({ label: "æ¥éåå ", value: item.reason }); |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | /** ä¿®æ¹åºæ¯å¿
é¡»å¸¦ä¸»é® IDï¼ä¸ Web ä¸è´ï¼ */ |
| | | export function validateReimbursementPersistDto(dto, isEdit) { |
| | | if (!isEdit) return { ok: true }; |
| | | if (dto?.id != null && dto.id !== "") return { ok: true }; |
| | | return { ok: false, message: "æ æ³ä¿®æ¹ï¼ç¼ºå°æ¥éå ID" }; |
| | | } |
| | | |
| | | export { deleteFinReimbursement, getFinReimbursementDetail, persistFinReimbursement }; |
| | | |
| | | /** å表è¡ä¸»é®ï¼å é¤/ä¿®æ¹ç¨ fin_reimbursement.idï¼å¿ç¨ item.id 审æ¹å®ä¾ IDï¼ */ |
| | | export function resolveReimbursementDeleteId(row) { |
| | | const raw = row?.reimbursementId; |
| | | if (raw == null || raw === "" || String(raw).startsWith("local_")) { |
| | | return undefined; |
| | | } |
| | | const n = Number(raw); |
| | | return Number.isNaN(n) ? raw : n; |
| | | } |
| | | |
| | | /** æ¯å¦å
许å é¤ï¼å®¡æ¹ä¸ãå·²éè¿ã已仿¬¾ä¸å¯å ï¼ */ |
| | | export function canDeleteReimbursementRow(row) { |
| | | const upper = String(row?.billStatus ?? row?.status ?? "").trim().toUpperCase(); |
| | | if (upper === "PAID") return false; |
| | | const key = mapBillStatusToApprovalKey( |
| | | row?.billStatus ?? row?.approvalStatus ?? row?.status |
| | | ); |
| | | return key !== "pending" && key !== "approved"; |
| | | } |
| | | |
| | | export function canEditReimbursementRow(row) { |
| | | return canDeleteReimbursementRow(row); |
| | | } |
| | | |
| | | /** æåæ¥é详æ
ï¼å«æç»ã审æ¹èç¹ï¼ä¸ Web mapFinReimbursementDetailRow ä¸è´ï¼ */ |
| | | export async function fetchFinReimbursementListItemDetail(item, reimbursementTypeOrModuleKey) { |
| | | const id = resolveReimbursementDeleteId(item); |
| | | if (id == null) { |
| | | throw new Error("missing reimbursement id"); |
| | | } |
| | | const res = await getFinReimbursementDetail(id); |
| | | const raw = unwrapFinReimbursementDetail(res); |
| | | const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey); |
| | | const row = mapFinReimbursementDetailRow(raw, type); |
| | | return { |
| | | ...row, |
| | | reimbursementType: type, |
| | | reimbursementTypeLabel: reimbursementTypeLabel(type), |
| | | moduleKey: getModuleKeyByReimbursementType(type), |
| | | displayRows: buildFinReimbursementDisplayRows( |
| | | { |
| | | billNo: row.billNo || row.reimburseNo, |
| | | applyAmount: row.applyAmount, |
| | | billStatus: row.billStatus, |
| | | departurePlace: row.departurePlace, |
| | | destination: row.destination, |
| | | expenseType: row.expenseCategory || row.expenseType, |
| | | reason: row.reimburseReason || row.reason, |
| | | }, |
| | | type |
| | | ), |
| | | }; |
| | | } |
| | | |
| | | function toNumber(val) { |
| | | if (val == null || val === "") return undefined; |
| | | const n = Number(val); |
| | | return Number.isNaN(n) ? undefined : n; |
| | | } |
| | | |
| | | function mapSignModeToApi(signMode) { |
| | | return signMode === "or_sign" ? "OR" : "AND"; |
| | | } |
| | | |
| | | function expenseSubjectToCategory(subject) { |
| | | const hit = |
| | | TRAVEL_EXPENSE_SUBJECTS.find(x => x.value === subject) || |
| | | COST_EXPENSE_SUBJECTS.find(x => x.value === subject); |
| | | return hit?.label || subject || ""; |
| | | } |
| | | |
| | | function mapDetailRowFromApi(d, reimbursementType) { |
| | | const type = normalizeReimbursementType(reimbursementType); |
| | | const raw = d.expenseCategory ?? d.expenseSubject ?? ""; |
| | | const opts = |
| | | type === FIN_REIMBURSEMENT_TYPE.TRAVEL |
| | | ? TRAVEL_EXPENSE_SUBJECTS |
| | | : COST_EXPENSE_SUBJECTS; |
| | | const label = resolveExpenseSubjectLabel(raw, { |
| | | isTravel: type === FIN_REIMBURSEMENT_TYPE.TRAVEL, |
| | | subjectOptions: opts, |
| | | }); |
| | | const hit = opts.find(x => x.value === raw || x.label === raw || x.label === label); |
| | | return { |
| | | ...d, |
| | | expenseSubject: hit?.value || raw, |
| | | }; |
| | | } |
| | | |
| | | function expenseCategoryToType(category) { |
| | | const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.value === category); |
| | | return hit?.label || category || ""; |
| | | } |
| | | |
| | | /** æ¥å£ nodes â 页é¢å®¡æ¹æµ */ |
| | | export function mapNodesToFormFlow(nodes = []) { |
| | | return (Array.isArray(nodes) ? nodes : []).map((n, i) => { |
| | | const first = Array.isArray(n.approvers) ? n.approvers[0] : null; |
| | | return { |
| | | ...n, |
| | | nodeOrder: n.levelNo ?? n.nodeOrder ?? i + 1, |
| | | signMode: String(n.approveType || "").toUpperCase() === "OR" ? "or_sign" : "countersign", |
| | | approverId: first?.approverId ?? n.approverId ?? "", |
| | | approverName: first?.approverName ?? n.approverName ?? "", |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | /** 页é¢å®¡æ¹èç¹ â æ¥å£ nodes */ |
| | | export function mapApprovalFlowNodesToApi(nodes = [], templateId) { |
| | | const list = Array.isArray(nodes) ? nodes : []; |
| | | return list |
| | | .map((n, i) => { |
| | | let approvers = []; |
| | | if (Array.isArray(n.approvers) && n.approvers.length) { |
| | | approvers = n.approvers |
| | | .filter(a => a?.approverId != null && a.approverId !== "") |
| | | .map((a, idx) => ({ |
| | | id: a.id, |
| | | nodeId: a.nodeId, |
| | | templateId: a.templateId ?? templateId, |
| | | approverId: toNumber(a.approverId) ?? a.approverId, |
| | | approverName: a.approverName || "", |
| | | sortNo: a.sortNo ?? idx + 1, |
| | | })); |
| | | } else if (n.approverId != null && n.approverId !== "") { |
| | | approvers = [ |
| | | { |
| | | approverId: toNumber(n.approverId) ?? n.approverId, |
| | | approverName: n.approverName || "", |
| | | sortNo: 1, |
| | | }, |
| | | ]; |
| | | } |
| | | if (!approvers.length) return null; |
| | | const node = { |
| | | levelNo: n.levelNo ?? n.nodeOrder ?? i + 1, |
| | | approveType: n.approveType || mapSignModeToApi(n.signMode), |
| | | approvers, |
| | | }; |
| | | if (n.id != null) node.id = n.id; |
| | | if (n.templateId != null) node.templateId = n.templateId; |
| | | else if (templateId != null) node.templateId = templateId; |
| | | return node; |
| | | }) |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | function mapDetailsToApi(details = []) { |
| | | return (details || []).map((d, i) => { |
| | | const item = { |
| | | rowNo: d.rowNo ?? i + 1, |
| | | invoiceDate: d.invoiceDate || undefined, |
| | | expenseCategory: expenseSubjectToCategory(d.expenseSubject ?? d.expenseCategory), |
| | | amount: toNumber(d.amount), |
| | | description: d.description || "", |
| | | invoiceNo: d.invoiceNo || undefined, |
| | | invoiceType: d.invoiceType || undefined, |
| | | invoiceAmount: toNumber(d.invoiceAmount), |
| | | taxRate: toNumber(d.taxRate), |
| | | taxAmount: toNumber(d.taxAmount), |
| | | remark: d.remark || undefined, |
| | | }; |
| | | if (d.id != null && !String(d.id).startsWith("ed_")) { |
| | | item.id = toNumber(d.id) ?? d.id; |
| | | } |
| | | if (d.reimbursementId != null) item.reimbursementId = toNumber(d.reimbursementId); |
| | | return item; |
| | | }); |
| | | } |
| | | |
| | | function sumDetailAmount(details = []) { |
| | | const sum = (details || []).reduce((s, d) => s + (Number(d.amount) || 0), 0); |
| | | return Math.round(sum * 100) / 100; |
| | | } |
| | | |
| | | function applyReimbursementRelations(dto) { |
| | | const rid = dto?.id; |
| | | if (rid == null) return dto; |
| | | if (dto.travel && typeof dto.travel === "object") { |
| | | dto.travel.reimbursementId = rid; |
| | | } |
| | | if (Array.isArray(dto.details)) { |
| | | dto.details.forEach(d => { |
| | | d.reimbursementId = rid; |
| | | }); |
| | | } |
| | | return dto; |
| | | } |
| | | |
| | | function resolveReimbursementId(form) { |
| | | const rawId = form?.reimbursementId ?? form?.id; |
| | | if (rawId == null || rawId === "" || String(rawId).startsWith("local_")) { |
| | | return undefined; |
| | | } |
| | | return toNumber(rawId) ?? rawId; |
| | | } |
| | | |
| | | /** æ¥å£è¡ â å·®æ
æ¥é表åè¡ */ |
| | | export function mapTravelReimbursementRow(row) { |
| | | if (!row) return {}; |
| | | const travel = pickTravelFromRow(row); |
| | | const details = Array.isArray(row.details) ? row.details : []; |
| | | return { |
| | | ...row, |
| | | id: row.id, |
| | | reimbursementId: row.id, |
| | | approvalInstanceId: row.approvalInstanceId, |
| | | reimburseNo: row.billNo || "", |
| | | applicantId: row.applicantId, |
| | | applicantNo: row.applicantCode || "", |
| | | applicantName: row.applicantName || "", |
| | | employeeNo: row.applicantCode || "", |
| | | employeeName: row.applicantName || "", |
| | | applicantDeptName: row.applicantDeptName || "", |
| | | reimburseReason: row.reason || "", |
| | | travelStartTime: formatReimbursementDateTime(travel.startTime), |
| | | travelEndTime: formatReimbursementDateTime(travel.endTime), |
| | | travelDays: travel.travelDays, |
| | | departurePlace: travel.departureCity || "", |
| | | destination: travel.destinationCity || "", |
| | | hotelStandard: travel.hotelStandard, |
| | | hotelDays: travel.lodgingDays, |
| | | livingSubsidy: travel.mealAllowance, |
| | | transportSubsidy: travel.transportAllowance, |
| | | lodgingLimit: travel.lodgingLimit, |
| | | needSpecialApproval: travel.withinStandard === "0" || travel.withinStandard === 0, |
| | | standardTag: travel.standardTag || "", |
| | | applyAmount: row.applyAmount, |
| | | payee: row.payeeName || "", |
| | | payeeAccount: row.payeeAccount || "", |
| | | payeeBank: row.payeeBank || "", |
| | | billStatus: row.billStatus, |
| | | expenseDetails: details.map(d => |
| | | mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.TRAVEL) |
| | | ), |
| | | travel: |
| | | row.travel && typeof row.travel === "object" && Object.keys(row.travel).length |
| | | ? row.travel |
| | | : travel, |
| | | details, |
| | | nodes: row.nodes || [], |
| | | approvalFlowNodes: mapNodesToFormFlow(row.nodes), |
| | | tasks: row.tasks || [], |
| | | attachmentList: row.attachmentList || row.invoiceAttachments || [], |
| | | }; |
| | | } |
| | | |
| | | /** æ¥å£è¡ â è´¹ç¨æ¥é表åè¡ */ |
| | | export function mapCostReimbursementRow(row) { |
| | | if (!row) return {}; |
| | | const details = Array.isArray(row.details) ? row.details : []; |
| | | return { |
| | | ...row, |
| | | id: row.id, |
| | | reimbursementId: row.id, |
| | | approvalInstanceId: row.approvalInstanceId, |
| | | reimburseNo: row.billNo || "", |
| | | applicantId: row.applicantId, |
| | | applicantNo: row.applicantCode || "", |
| | | applicantName: row.applicantName || "", |
| | | employeeNo: row.applicantCode || "", |
| | | employeeName: row.applicantName || "", |
| | | applicantDeptName: row.applicantDeptName || "", |
| | | reimburseReason: row.reason || "", |
| | | expenseCategory: expenseTypeToCategory(row.expenseType), |
| | | applyAmount: row.applyAmount, |
| | | applyTime: formatReimbursementDateTime(row.createTime), |
| | | createTime: formatReimbursementDateTime(row.createTime), |
| | | payee: row.payeeName || "", |
| | | payeeAccount: row.payeeAccount || "", |
| | | bankBranch: row.payeeBank || "", |
| | | payeeBank: row.payeeBank || "", |
| | | billStatus: row.billStatus, |
| | | expenseDetails: details.map(d => |
| | | mapDetailRowFromApi(d, FIN_REIMBURSEMENT_TYPE.COST) |
| | | ), |
| | | details, |
| | | nodes: row.nodes || [], |
| | | approvalFlowNodes: mapNodesToFormFlow(row.nodes), |
| | | tasks: row.tasks || [], |
| | | attachmentList: row.attachmentList || row.invoiceAttachments || [], |
| | | }; |
| | | } |
| | | |
| | | export function mapFinReimbursementDetailRow(raw, reimbursementTypeOrModuleKey) { |
| | | const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey); |
| | | let mapped = {}; |
| | | if (type === FIN_REIMBURSEMENT_TYPE.TRAVEL) { |
| | | mapped = mapTravelReimbursementRow(raw); |
| | | } else if (type === FIN_REIMBURSEMENT_TYPE.COST) { |
| | | mapped = mapCostReimbursementRow(raw); |
| | | } else { |
| | | mapped = raw || {}; |
| | | } |
| | | return { |
| | | ...applyFinReimbursementDetailEnrichment(mapped, raw), |
| | | reimbursementType: type, |
| | | reimbursementTypeLabel: reimbursementTypeLabel(type), |
| | | moduleKey: getModuleKeyByReimbursementType(type), |
| | | }; |
| | | } |
| | | |
| | | /** å·®æ
表å â FinReimbursementDto */ |
| | | export function buildTravelReimbursementSaveDto(form, { computeTravelDays } = {}) { |
| | | const details = mapDetailsToApi(form.expenseDetails); |
| | | const detailTotal = sumDetailAmount(form.expenseDetails); |
| | | const applyAmount = toNumber(form.applyAmount) ?? detailTotal; |
| | | const travelDays = |
| | | form.travelDays != null |
| | | ? toNumber(form.travelDays) |
| | | : computeTravelDays?.(form.travelStartTime, form.travelEndTime); |
| | | |
| | | const dto = { |
| | | reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL, |
| | | expenseType: "å·®æ
è´¹", |
| | | applicantId: toNumber(form.applicantId), |
| | | applicantCode: form.employeeNo || form.applicantNo || "", |
| | | applicantName: form.employeeName || form.applicantName || "", |
| | | applicantDeptId: toNumber(form.applicantDeptId), |
| | | applicantDeptName: form.applicantDeptName || form.deptName || "", |
| | | reason: (form.reimburseReason || "").trim(), |
| | | applyAmount, |
| | | detailTotalAmount: detailTotal, |
| | | payeeName: form.payee || "", |
| | | payeeAccount: form.payeeAccount || undefined, |
| | | payeeBank: form.payeeBank || undefined, |
| | | billStatus: "IN_APPROVAL", |
| | | deptId: toNumber(form.deptId), |
| | | travel: { |
| | | startTime: form.travelStartTime || undefined, |
| | | endTime: form.travelEndTime || undefined, |
| | | travelDays, |
| | | departureCity: form.departurePlace || "", |
| | | destinationCity: form.destination || "", |
| | | hotelStandard: toNumber(form.hotelStandard), |
| | | lodgingDays: toNumber(form.hotelDays), |
| | | mealAllowance: toNumber(form.livingSubsidy), |
| | | transportAllowance: toNumber(form.transportSubsidy), |
| | | lodgingLimit: toNumber(form.lodgingLimit), |
| | | standardTag: form.standardTag || (form.needSpecialApproval ? "è¶
æ ç¹æ¹" : "卿 åèå´å
"), |
| | | withinStandard: form.needSpecialApproval ? "0" : "1", |
| | | }, |
| | | details, |
| | | nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId), |
| | | }; |
| | | |
| | | const id = resolveReimbursementId(form); |
| | | if (id != null) dto.id = id; |
| | | if (form.billNo || form.reimburseNo) dto.billNo = form.billNo || form.reimburseNo; |
| | | if (form.approvalInstanceId != null) dto.approvalInstanceId = toNumber(form.approvalInstanceId); |
| | | if (form.approveProcessId != null) dto.approveProcessId = toNumber(form.approveProcessId); |
| | | if (form.travel?.id != null) dto.travel.id = toNumber(form.travel.id); |
| | | |
| | | return applyReimbursementRelations(dto); |
| | | } |
| | | |
| | | /** è´¹ç¨è¡¨å â FinReimbursementDto */ |
| | | export function buildCostReimbursementSaveDto(form) { |
| | | const details = mapDetailsToApi(form.expenseDetails); |
| | | const detailTotal = sumDetailAmount(form.expenseDetails); |
| | | const applyAmount = toNumber(form.applyAmount) ?? detailTotal; |
| | | |
| | | const dto = { |
| | | reimbursementType: FIN_REIMBURSEMENT_TYPE.COST, |
| | | expenseType: expenseCategoryToType(form.expenseCategory), |
| | | applicantId: toNumber(form.applicantId), |
| | | applicantCode: form.employeeNo || form.applicantNo || "", |
| | | applicantName: form.employeeName || form.applicantName || "", |
| | | applicantDeptId: toNumber(form.applicantDeptId), |
| | | applicantDeptName: form.applicantDeptName || form.deptName || "", |
| | | reason: (form.reimburseReason || "").trim(), |
| | | applyAmount, |
| | | detailTotalAmount: detailTotal, |
| | | payeeName: form.payee || "", |
| | | payeeAccount: form.payeeAccount || "", |
| | | payeeBank: form.bankBranch || form.payeeBank || "", |
| | | billStatus: "IN_APPROVAL", |
| | | deptId: toNumber(form.deptId), |
| | | details, |
| | | nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId), |
| | | }; |
| | | |
| | | const id = resolveReimbursementId(form); |
| | | if (id != null) dto.id = id; |
| | | if (form.billNo || form.reimburseNo) dto.billNo = form.billNo || form.reimburseNo; |
| | | if (form.approvalInstanceId != null) dto.approvalInstanceId = toNumber(form.approvalInstanceId); |
| | | if (form.approveProcessId != null) dto.approveProcessId = toNumber(form.approveProcessId); |
| | | |
| | | return applyReimbursementRelations(dto); |
| | | } |
| | | |
| | | /** å¡«æ¥é¡µå 载详æ
ï¼ä¸ Web openFormDialog edit ä¸è´ï¼ */ |
| | | export async function fetchFinReimbursementFormDetail(item, moduleKey) { |
| | | const id = resolveReimbursementDeleteId(item); |
| | | if (id == null) throw new Error("missing reimbursement id"); |
| | | const res = await getFinReimbursementDetail(id); |
| | | const raw = unwrapFinReimbursementDetail(res); |
| | | return mapFinReimbursementDetailRow(raw, moduleKey); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { matchBusinessTypeValue } from "./approvalTemplateType.js"; |
| | | import { |
| | | APPROVAL_MODULE_KEYS, |
| | | getApprovalModuleConfig, |
| | | } from "./approvalModuleRegistry.js"; |
| | | import { fetchFinReimbursementListItemDetail } from "./finReimbursementMappers.js"; |
| | | |
| | | export const REIMBURSE_EDIT_FROM_APPROVE_KEY = "oa_reimburse_edit_from_approve"; |
| | | export const FIN_REIMBURSE_FORM_ACTION_KEY = "oa_fin_reimburse_form_action"; |
| | | |
| | | const REIMBURSE_MODULE_KEYS = [ |
| | | APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE, |
| | | APPROVAL_MODULE_KEYS.COST_REIMBURSE, |
| | | ]; |
| | | |
| | | export function inferReimburseModuleKeyFromInstance(row) { |
| | | if (!row) return ""; |
| | | for (const moduleKey of REIMBURSE_MODULE_KEYS) { |
| | | const cfg = getApprovalModuleConfig(moduleKey); |
| | | if (!cfg) continue; |
| | | if ( |
| | | cfg.businessType != null && |
| | | cfg.businessType !== "" && |
| | | matchBusinessTypeValue(row.businessType, cfg.businessType) |
| | | ) { |
| | | return moduleKey; |
| | | } |
| | | if (matchBusinessTypeValue(row.businessType, cfg.approvalType)) { |
| | | return moduleKey; |
| | | } |
| | | const text = `${row.templateName || ""}${row.title || ""}${row.businessName || ""}`; |
| | | if ((cfg.typeLabels || []).some(l => l && text.includes(l))) { |
| | | return moduleKey; |
| | | } |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | export function isReimburseApprovalInstance(row) { |
| | | return Boolean(inferReimburseModuleKeyFromInstance(row)); |
| | | } |
| | | |
| | | export function resolveFinReimbursementIdFromInstance(row) { |
| | | const raw = row?.businessId ?? row?.formPayload?.reimbursementId; |
| | | if (raw == null || raw === "") return undefined; |
| | | const n = Number(raw); |
| | | return Number.isNaN(n) ? raw : n; |
| | | } |
| | | |
| | | export async function loadReimburseDetailForInstance(instanceRow, moduleKey) { |
| | | const mk = moduleKey || inferReimburseModuleKeyFromInstance(instanceRow); |
| | | const id = resolveFinReimbursementIdFromInstance(instanceRow); |
| | | if (id == null) { |
| | | throw new Error("missing reimbursement id"); |
| | | } |
| | | const reimburseRow = await fetchFinReimbursementListItemDetail( |
| | | { reimbursementId: id }, |
| | | mk |
| | | ); |
| | | return { |
| | | reimburseRow, |
| | | instanceRow, |
| | | moduleKey: reimburseRow.moduleKey || mk, |
| | | reimbursementType: reimburseRow.reimbursementType, |
| | | }; |
| | | } |
| | | |
| | | export function stashReimburseEditFromApprove(moduleKey, reimbursementId) { |
| | | uni.setStorageSync( |
| | | REIMBURSE_EDIT_FROM_APPROVE_KEY, |
| | | JSON.stringify({ moduleKey, reimbursementId }) |
| | | ); |
| | | } |
| | | |
| | | export function consumeReimburseEditFromApprove() { |
| | | const raw = uni.getStorageSync(REIMBURSE_EDIT_FROM_APPROVE_KEY); |
| | | if (!raw) return null; |
| | | uni.removeStorageSync(REIMBURSE_EDIT_FROM_APPROVE_KEY); |
| | | try { |
| | | return typeof raw === "string" ? JSON.parse(raw) : raw; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | export function stashFinReimburseFormAction(payload) { |
| | | uni.setStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY, JSON.stringify(payload)); |
| | | } |
| | | |
| | | export function consumeFinReimburseFormAction() { |
| | | const raw = uni.getStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY); |
| | | if (!raw) return null; |
| | | uni.removeStorageSync(FIN_REIMBURSE_FORM_ACTION_KEY); |
| | | try { |
| | | return typeof raw === "string" ? JSON.parse(raw) : raw; |
| | | } catch { |
| | | return null; |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /** ç¨æ·å表解å
*/ |
| | | export function unwrapUserList(res) { |
| | | if (Array.isArray(res)) return res; |
| | | if (Array.isArray(res?.data)) return res.data; |
| | | if (Array.isArray(res?.rows)) return res.rows; |
| | | return []; |
| | | } |
| | | |
| | | export function isActiveUser(u) { |
| | | if (u?.delFlag === "2" || u?.delFlag === 2) return false; |
| | | if (u?.status == null) return true; |
| | | return String(u.status) === "0"; |
| | | } |
| | | |
| | | export function userSelectLabel(u) { |
| | | const nick = u?.nickName || ""; |
| | | const name = u?.userName || ""; |
| | | if (nick && name && nick !== name) return `${nick}ï¼${name}ï¼`; |
| | | return nick || name || `ç¨æ·${u?.userId ?? u?.id ?? ""}`; |
| | | } |
| | | |
| | | export function userSubLabel(u) { |
| | | const parts = []; |
| | | const code = u?.userName || u?.userCode || ""; |
| | | if (code) parts.push(`å·¥å· ${code}`); |
| | | const dept = u?.dept?.deptName ?? u?.deptName ?? ""; |
| | | if (dept) parts.push(dept); |
| | | return parts.join(" · ") || ""; |
| | | } |
| | | |
| | | const AVATAR_COLORS = ["#409EFF", "#67C23A", "#E6A23C", "#9B59B6", "#1ABC9C", "#F56C6C"]; |
| | | |
| | | export function userAvatarColor(name) { |
| | | if (!name) return "#c0c4cc"; |
| | | let h = 0; |
| | | for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h); |
| | | return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]; |
| | | } |
| | | |
| | | /** æå§å/å·¥å·/ID æç´¢ï¼ç©ºå
³é®åæ¶ä¼å
å±ç¤ºå limit æ¡ */ |
| | | export function filterActiveUsers(list, keyword, limit = 80) { |
| | | const active = (list || []).filter(isActiveUser); |
| | | const q = (keyword || "").trim().toLowerCase(); |
| | | if (!q) return active.slice(0, limit); |
| | | return active |
| | | .filter(u => { |
| | | const nick = (u.nickName || "").toLowerCase(); |
| | | const name = (u.userName || "").toLowerCase(); |
| | | const id = String(u.userId ?? u.id ?? ""); |
| | | return nick.includes(q) || name.includes(q) || id.includes(q); |
| | | }) |
| | | .slice(0, limit); |
| | | } |