feat: 薪酬核算列表行内编辑、工单处理人下拉搜索、社保比例两位小数、退货客户可编辑
- 薪酬核算列表:金额/出勤/加班列改为文本框直接编辑,工具栏新增「保存修改」(仅待确认可改)
- 售后工单:新增表单与详情「分配处理人」改为可搜索下拉(/system/user/simple-list),修复手输 ID 导致处理人为空
- 社保公积金方案:比例统一按百分比两位小数显示与输入,去掉自动取整
- 退货申请:客户字段改为可编辑文本框,不再强制关联客户档案
| | |
| | | label: '客户', |
| | | component: 'Input', |
| | | componentProps: { |
| | | disabled: true, |
| | | placeholder: '客户', |
| | | placeholder: '请输入客户名称', |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderId', |
| | |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { AftersalesTicketApi } from '#/api/aftersales/ticket'; |
| | | |
| | | import { h, markRaw } from 'vue'; |
| | | import { h } from 'vue'; |
| | | |
| | | import { DICT_TYPE } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | |
| | | import { Button, Tag } from 'ant-design-vue'; |
| | | |
| | | import { z } from '#/adapter/form'; |
| | | import { UserSelect } from '#/views/system/user/components'; |
| | | import { getSimpleUserList } from '#/api/system/user'; |
| | | import { getDeptList } from '#/api/system/dept'; |
| | | import { handleTree } from '#/packages/utils/src'; |
| | | |
| | |
| | | { |
| | | fieldName: 'handlerUserId', |
| | | label: '处理人', |
| | | component: markRaw(UserSelect), |
| | | component: 'ApiSelect', |
| | | componentProps: { |
| | | placeholder: '请选择处理人', |
| | | api: getSimpleUserList, |
| | | labelField: 'nickname', |
| | | valueField: 'id', |
| | | showSearch: true, |
| | | filterOption: (input: string, option: any) => |
| | | `${option?.label ?? ''}`.toLowerCase().includes(input.toLowerCase()) || |
| | | `${option?.value ?? ''}`.includes(input), |
| | | allowClear: true, |
| | | placeholder: '请选择处理人(可搜索昵称)', |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | |
| | | Tag, |
| | | } from 'ant-design-vue'; |
| | | |
| | | import { |
| | | assignTicket, |
| | | cancelTicket, |
| | | classifyTicket, |
| | | closeTicket, |
| | | getTicket, |
| | | resolveTicket, |
| | | } from '#/api/aftersales/ticket'; |
| | | import { assignTicket, cancelTicket, classifyTicket, closeTicket, getTicket, resolveTicket } from '#/api/aftersales/ticket'; |
| | | import { getSimpleUserList } from '#/api/system/user'; |
| | | import type { AftersalesVisitApi } from '#/api/aftersales/visit'; |
| | | import { |
| | | createVisit, |
| | |
| | | // 分配处理人 |
| | | const showAssignInput = ref(false); |
| | | const assignUserId = ref<number>(); |
| | | const userOptions = ref<{ label: string; value: number }[]>([]); |
| | | const userLoading = ref(false); |
| | | |
| | | /** 加载可选的系统用户(可搜索昵称) */ |
| | | async function loadUserOptions() { |
| | | if (userOptions.value.length > 0) { |
| | | return; |
| | | } |
| | | userLoading.value = true; |
| | | try { |
| | | const list = (await getSimpleUserList()) || []; |
| | | userOptions.value = list.map((u: any) => ({ |
| | | label: u.nickname || u.username || String(u.id), |
| | | value: u.id, |
| | | })); |
| | | } finally { |
| | | userLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | // 问题判定 |
| | | const showClassifyInput = ref(false); |
| | |
| | | function toggleAssignInput() { |
| | | showAssignInput.value = !showAssignInput.value; |
| | | assignUserId.value = undefined; |
| | | if (showAssignInput.value) { |
| | | loadUserOptions(); |
| | | } |
| | | } |
| | | |
| | | async function handleAssign() { |
| | | if (!assignUserId.value) { |
| | | message.warning('请输入处理人ID'); |
| | | message.warning('请选择处理人'); |
| | | return; |
| | | } |
| | | await assignTicket(ticketId.value, assignUserId.value); |
| | |
| | | <Button @click="toggleAssignInput">分配处理人</Button> |
| | | </template> |
| | | <template v-else> |
| | | <Input |
| | | <Select |
| | | v-model:value="assignUserId" |
| | | placeholder="处理人ID" |
| | | type="number" |
| | | style="width: 120px" |
| | | @pressEnter="handleAssign" |
| | | :options="userOptions" |
| | | :loading="userLoading" |
| | | show-search |
| | | option-filter-prop="label" |
| | | placeholder="请选择处理人" |
| | | style="width: 200px" |
| | | /> |
| | | <Button size="small" type="primary" @click="handleAssign">确认</Button> |
| | | <Button size="small" @click="toggleAssignInput">取消</Button> |
| | |
| | | { field: 'period', title: '核算周期', width: 100 }, |
| | | { field: 'userName', title: '员工姓名', minWidth: 100 }, |
| | | { field: 'deptName', title: '部门', minWidth: 120 }, |
| | | { field: 'baseSalary', title: '基本工资', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'performanceSalary', title: '绩效工资', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'overtimePay', title: '加班费', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'mealAllowance', title: '餐补', width: 80, formatter: 'formatAmount2' }, |
| | | { field: 'transportAllowance', title: '交通补贴', width: 80, formatter: 'formatAmount2' }, |
| | | { field: 'otherAllowance', title: '其他补贴', width: 80, formatter: 'formatAmount2' }, |
| | | { field: 'socialSecurityDeduction', title: '社保扣款', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'housingFundDeduction', title: '公积金扣款', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'taxDeduction', title: '个税扣款', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'otherDeduction', title: '其他扣款', width: 80, formatter: 'formatAmount2' }, |
| | | { field: 'leaveDeduction', title: '请假扣款', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'actualSalary', title: '实发工资', width: 100, formatter: 'formatAmount2' }, |
| | | { field: 'workDays', title: '出勤天数', width: 80 }, |
| | | { field: 'overtimeHours', title: '加班时长(h)', width: 100 }, |
| | | { field: 'totalWorkHours', title: '排班工时(h)', width: 100 }, |
| | | { field: 'baseSalary', title: '基本工资', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'performanceSalary', title: '绩效工资', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'overtimePay', title: '加班费', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'mealAllowance', title: '餐补', width: 110, slots: { default: 'editableCell' } }, |
| | | { field: 'transportAllowance', title: '交通补贴', width: 110, slots: { default: 'editableCell' } }, |
| | | { field: 'otherAllowance', title: '其他补贴', width: 110, slots: { default: 'editableCell' } }, |
| | | { field: 'socialSecurityDeduction', title: '社保扣款', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'housingFundDeduction', title: '公积金扣款', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'taxDeduction', title: '个税扣款', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'otherDeduction', title: '其他扣款', width: 110, slots: { default: 'editableCell' } }, |
| | | { field: 'leaveDeduction', title: '请假扣款', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'actualSalary', title: '实发工资', width: 110, formatter: 'formatAmount2' }, |
| | | { field: 'workDays', title: '出勤天数', width: 110, slots: { default: 'editableCell' } }, |
| | | { field: 'overtimeHours', title: '加班时长(h)', width: 120, slots: { default: 'editableCell' } }, |
| | | { field: 'totalWorkHours', title: '排班工时(h)', width: 110 }, |
| | | { |
| | | field: 'status', |
| | | title: '状态', |
| | |
| | | import { Page } from '#/packages/effects/common-ui/src'; |
| | | import { downloadFileFromBlobPart } from '#/packages/utils/src'; |
| | | |
| | | import { message, Modal, Form, FormItem, DatePicker, TreeSelect, Table, InputNumber, Alert, Tag } from 'ant-design-vue'; |
| | | import { message, Modal, Form, FormItem, DatePicker, TreeSelect, Table, Input, InputNumber, Alert, Tag } from 'ant-design-vue'; |
| | | |
| | | import { DICT_TYPE } from '#/packages/constants/src'; |
| | | import { getDictLabel } from '#/packages/effects/hooks/src'; |
| | |
| | | previewSalaryCalculation, |
| | | revokeSalaryCalculation, |
| | | saveSalaryCalculation, |
| | | updateSalaryCalculation, |
| | | } from '#/api/hrm/salary/calculation'; |
| | | import { getDeptList } from '#/api/system/dept'; |
| | | import { handleTree } from '#/packages/utils/src'; |
| | |
| | | |
| | | // 核算弹窗相关 |
| | | const formModalRef = ref(); |
| | | const saving = ref(false); |
| | | const calculateVisible = ref(false); |
| | | const calculatePeriod = ref<string>(); |
| | | const calculateDeptId = ref<number>(); |
| | |
| | | async function handleExport() { |
| | | const data = await exportSalaryCalculation(await gridApi.formApi.getValues()); |
| | | downloadFileFromBlobPart({ fileName: '薪酬核算表.xls', source: data }); |
| | | } |
| | | |
| | | /** 列表可编辑的数值字段 */ |
| | | const EDITABLE_NUMBER_FIELDS = [ |
| | | 'baseSalary', |
| | | 'performanceSalary', |
| | | 'overtimePay', |
| | | 'mealAllowance', |
| | | 'transportAllowance', |
| | | 'otherAllowance', |
| | | 'socialSecurityDeduction', |
| | | 'housingFundDeduction', |
| | | 'taxDeduction', |
| | | 'otherDeduction', |
| | | 'leaveDeduction', |
| | | 'workDays', |
| | | 'overtimeHours', |
| | | ]; |
| | | |
| | | /** 文本输入转数字(空值按 0) */ |
| | | function toNumberOrZero(val: any) { |
| | | if (val === '' || val === null || val === undefined) { |
| | | return 0; |
| | | } |
| | | const num = Number(String(val).replace(/,/g, '')); |
| | | return Number.isNaN(num) ? 0 : num; |
| | | } |
| | | |
| | | /** 保存列表内联修改(仅待确认状态) */ |
| | | async function handleSaveEdits() { |
| | | const rows = (gridApi.grid.getData() as any[]).filter((r) => r.status === 0); |
| | | if (rows.length === 0) { |
| | | message.warning('当前页没有待确认的记录可修改'); |
| | | return; |
| | | } |
| | | saving.value = true; |
| | | try { |
| | | for (const row of rows) { |
| | | const payload: any = { ...row }; |
| | | EDITABLE_NUMBER_FIELDS.forEach((f) => { |
| | | payload[f] = toNumberOrZero(payload[f]); |
| | | }); |
| | | await updateSalaryCalculation(payload); |
| | | } |
| | | message.success(`已保存 ${rows.length} 条核算记录`); |
| | | handleRefresh(); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | |
| | | onClick: handleBatchSave, |
| | | }, |
| | | { |
| | | label: '保存修改', |
| | | type: 'primary', |
| | | auth: ['hrm:salary-calculation:calculate'], |
| | | loading: saving, |
| | | onClick: handleSaveEdits, |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.export'), |
| | | type: 'primary', |
| | | icon: ACTION_ICON.DOWNLOAD, |
| | |
| | | ]" |
| | | /> |
| | | </template> |
| | | <template #editableCell="{ row, column }"> |
| | | <Input |
| | | v-if="row.status === 0" |
| | | v-model:value="row[column.field]" |
| | | size="small" |
| | | :style="{ width: '100%' }" |
| | | /> |
| | | <span v-else>{{ row[column.field] ?? '' }}</span> |
| | | </template> |
| | | <template #status="{ row }"> |
| | | <Tag :color="row.status === 0 ? 'warning' : row.status === 10 ? 'success' : 'processing'"> |
| | | {{ getDictLabel(DICT_TYPE.HRM_SALARY_STATUS, row.status) }} |
| | |
| | | /** 表单类型 */ |
| | | export type FormType = 'create' | 'update'; |
| | | |
| | | /** 格式化比例输入 */ |
| | | /** 格式化比例输入(统一显示两位小数百分比,如 8.25%) */ |
| | | function createRatioFormatter(step: number = 0.01) { |
| | | return { |
| | | formatter: (value: number) => value !== undefined && value !== null ? `${(value * 100).toFixed(step === 0.001 ? 1 : 0)}%` : '', |
| | | formatter: (value: number) => |
| | | value !== undefined && value !== null ? `${(value * 100).toFixed(2)}%` : '', |
| | | parser: (value: string) => { |
| | | const num = parseFloat(value.replace('%', '')); |
| | | const num = parseFloat(String(value).replace('%', '')); |
| | | return isNaN(num) ? 0 : num / 100; |
| | | }, |
| | | }; |
| | |
| | | handleRefresh(); |
| | | } |
| | | |
| | | /** 格式化比例显示 */ |
| | | /** 格式化比例显示(保留两位小数) */ |
| | | function formatRatio(ratio: number | undefined) { |
| | | if (ratio === undefined || ratio === null) return '-'; |
| | | return `${(ratio * 100).toFixed(1)}%`; |
| | | return `${(ratio * 100).toFixed(2)}%`; |
| | | } |
| | | |
| | | /** 格式化比例显示(保留两位小数) */ |