<script lang="ts" setup>
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { HrmSalaryCalculationApi } from '#/api/hrm/salary/calculation';
|
|
import { ref } from 'vue';
|
|
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 { DICT_TYPE } from '#/packages/constants/src';
|
import { getDictLabel } from '#/packages/effects/hooks/src';
|
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
import {
|
calculateSalary,
|
confirmSalary,
|
exportSalaryCalculation,
|
getSalaryCalculationPage,
|
previewSalaryCalculation,
|
saveSalaryCalculation,
|
} from '#/api/hrm/salary/calculation';
|
import { getDeptList } from '#/api/system/dept';
|
import { handleTree } from '#/packages/utils/src';
|
import { $t } from '#/locales';
|
|
import { useGridColumns, useGridFormSchema, usePreviewColumns } from './data';
|
|
// 核算弹窗相关
|
const calculateVisible = ref(false);
|
const calculatePeriod = ref<string>();
|
const calculateDeptId = ref<number>();
|
const deptTreeData = ref<any[]>([]);
|
const previewLoading = ref(false);
|
const previewData = ref<HrmSalaryCalculationApi.SalaryCalculation[]>([]);
|
const submitLoading = ref(false);
|
const calculateLoading = ref(false);
|
|
// 打开核算弹窗
|
async function openCalculateModal() {
|
const formValues = await gridApi.formApi.getValues();
|
calculatePeriod.value = formValues.period;
|
calculateDeptId.value = formValues.deptId;
|
previewData.value = [];
|
// 加载部门树
|
const data = await getDeptList();
|
deptTreeData.value = handleTree(data);
|
calculateVisible.value = true;
|
}
|
|
/** 刷新表格 */
|
function handleRefresh() {
|
gridApi.query();
|
}
|
|
/** 执行薪酬核算(自动计算并保存) */
|
async function handleCalculate() {
|
if (!calculatePeriod.value) {
|
message.warning('请选择核算周期');
|
return;
|
}
|
calculateLoading.value = true;
|
try {
|
await calculateSalary({
|
period: calculatePeriod.value,
|
deptId: calculateDeptId.value,
|
});
|
message.success('薪酬核算执行成功');
|
calculateVisible.value = false;
|
handleRefresh();
|
} finally {
|
calculateLoading.value = false;
|
}
|
}
|
|
/** 预览核算数据 */
|
async function handlePreview() {
|
if (!calculatePeriod.value) {
|
message.warning('请选择核算周期');
|
return;
|
}
|
previewLoading.value = true;
|
try {
|
previewData.value = await previewSalaryCalculation({
|
period: calculatePeriod.value,
|
deptId: calculateDeptId.value,
|
});
|
if (previewData.value.length === 0) {
|
message.info('该条件下没有需要核算的员工');
|
}
|
} finally {
|
previewLoading.value = false;
|
}
|
}
|
|
/** 计算实发工资 */
|
function calculateActualSalary(record: HrmSalaryCalculationApi.SalaryCalculation) {
|
const income = (record.baseSalary || 0) +
|
(record.performanceSalary || 0) +
|
(record.overtimePay || 0) +
|
(record.mealAllowance || 0) +
|
(record.transportAllowance || 0) +
|
(record.otherAllowance || 0);
|
const deduction = (record.socialSecurityDeduction || 0) +
|
(record.housingFundDeduction || 0) +
|
(record.taxDeduction || 0) +
|
(record.otherDeduction || 0) +
|
(record.leaveDeduction || 0);
|
return income - deduction;
|
}
|
|
/** 更新行数据并计算实发工资 */
|
function updateFieldValue(record: HrmSalaryCalculationApi.SalaryCalculation, field: keyof HrmSalaryCalculationApi.SalaryCalculation, value: number) {
|
(record as any)[field] = value;
|
record.actualSalary = calculateActualSalary(record);
|
}
|
|
/** 提交薪酬核算(手动调整后保存) */
|
async function handleSubmit() {
|
if (!calculatePeriod.value) {
|
message.warning('请选择核算周期');
|
return;
|
}
|
if (previewData.value.length === 0) {
|
message.warning('请先预览核算数据');
|
return;
|
}
|
submitLoading.value = true;
|
try {
|
await saveSalaryCalculation({
|
period: calculatePeriod.value,
|
list: previewData.value,
|
});
|
message.success('核算保存成功');
|
calculateVisible.value = false;
|
handleRefresh();
|
} finally {
|
submitLoading.value = false;
|
}
|
}
|
|
/** 取消弹窗 */
|
function handleCancel() {
|
calculateVisible.value = false;
|
previewData.value = [];
|
}
|
|
/** 确认薪酬核算 */
|
async function handleConfirm(row: HrmSalaryCalculationApi.SalaryCalculation) {
|
await confirmSalary([row.id!]);
|
message.success('确认成功');
|
handleRefresh();
|
}
|
|
/** 批量确认 */
|
async function handleBatchConfirm() {
|
const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[];
|
if (records.length === 0) {
|
message.warning('请选择要确认的记录');
|
return;
|
}
|
const ids = records.map((r) => r.id!);
|
await confirmSalary(ids);
|
message.success('确认成功');
|
handleRefresh();
|
}
|
|
/** 导出薪酬核算 */
|
async function handleExport() {
|
const data = await exportSalaryCalculation(await gridApi.formApi.getValues());
|
downloadFileFromBlobPart({ fileName: '薪酬核算表.xls', source: data });
|
}
|
|
const [Grid, gridApi] = useVbenVxeGrid({
|
formOptions: {
|
schema: useGridFormSchema(),
|
},
|
gridOptions: {
|
columns: useGridColumns(),
|
height: 'auto',
|
keepSource: true,
|
checkboxConfig: {
|
highlight: true,
|
reserve: true,
|
},
|
proxyConfig: {
|
ajax: {
|
query: async ({ page }, formValues) =>
|
await getSalaryCalculationPage({
|
pageNo: page.currentPage,
|
pageSize: page.pageSize,
|
...formValues,
|
}),
|
},
|
},
|
rowConfig: {
|
keyField: 'id',
|
isHover: true,
|
},
|
toolbarConfig: {
|
refresh: true,
|
search: true,
|
},
|
} as VxeTableGridOptions<HrmSalaryCalculationApi.SalaryCalculation>,
|
});
|
</script>
|
|
<template>
|
<Page auto-content-height>
|
<!-- 核算弹窗 -->
|
<Modal
|
v-model:open="calculateVisible"
|
title="薪酬核算"
|
width="1200px"
|
:footer="null"
|
:maskClosable="false"
|
>
|
<Alert
|
message="两种核算方式:1. 执行核算 - 自动计算并保存;2. 预览数据 - 手动调整后保存"
|
type="info"
|
show-icon
|
style="margin-bottom: 16px"
|
/>
|
|
<Form layout="inline" class="mb-4">
|
<FormItem label="核算周期" required>
|
<DatePicker
|
v-model:value="calculatePeriod"
|
picker="month"
|
format="YYYY-MM"
|
valueFormat="YYYY-MM"
|
placeholder="请选择核算周期"
|
style="width: 180px"
|
/>
|
</FormItem>
|
<FormItem label="核算部门">
|
<TreeSelect
|
v-model:value="calculateDeptId"
|
:tree-data="deptTreeData"
|
:field-names="{ label: 'name', value: 'id', children: 'children' }"
|
placeholder="全部部门"
|
allow-clear
|
tree-default-expand-all
|
style="width: 200px"
|
/>
|
</FormItem>
|
<FormItem>
|
<a-button type="primary" :loading="calculateLoading" @click="handleCalculate">
|
执行核算
|
</a-button>
|
</FormItem>
|
<FormItem>
|
<a-button :loading="previewLoading" @click="handlePreview">
|
预览数据
|
</a-button>
|
</FormItem>
|
</Form>
|
|
<Table
|
v-if="previewData.length > 0"
|
:columns="usePreviewColumns()"
|
:data-source="previewData"
|
:pagination="false"
|
:scroll="{ x: 1800, y: 400 }"
|
size="small"
|
row-key="userId"
|
bordered
|
class="mb-4"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.dataIndex === 'baseSalary'">
|
<InputNumber
|
v-model:value="record.baseSalary"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 90px"
|
@change="(val: number) => updateFieldValue(record, 'baseSalary', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'performanceSalary'">
|
<InputNumber
|
v-model:value="record.performanceSalary"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 90px"
|
@change="(val: number) => updateFieldValue(record, 'performanceSalary', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'overtimePay'">
|
<InputNumber
|
v-model:value="record.overtimePay"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 90px"
|
@change="(val: number) => updateFieldValue(record, 'overtimePay', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'mealAllowance'">
|
<InputNumber
|
v-model:value="record.mealAllowance"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 80px"
|
@change="(val: number) => updateFieldValue(record, 'mealAllowance', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'transportAllowance'">
|
<InputNumber
|
v-model:value="record.transportAllowance"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 80px"
|
@change="(val: number) => updateFieldValue(record, 'transportAllowance', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'otherAllowance'">
|
<InputNumber
|
v-model:value="record.otherAllowance"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 80px"
|
@change="(val: number) => updateFieldValue(record, 'otherAllowance', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'socialSecurityDeduction'">
|
<InputNumber
|
v-model:value="record.socialSecurityDeduction"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 90px"
|
@change="(val: number) => updateFieldValue(record, 'socialSecurityDeduction', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'housingFundDeduction'">
|
<InputNumber
|
v-model:value="record.housingFundDeduction"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 90px"
|
@change="(val: number) => updateFieldValue(record, 'housingFundDeduction', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'taxDeduction'">
|
<InputNumber
|
v-model:value="record.taxDeduction"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 90px"
|
@change="(val: number) => updateFieldValue(record, 'taxDeduction', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'otherDeduction'">
|
<InputNumber
|
v-model:value="record.otherDeduction"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 80px"
|
@change="(val: number) => updateFieldValue(record, 'otherDeduction', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'leaveDeduction'">
|
<InputNumber
|
v-model:value="record.leaveDeduction"
|
:min="0"
|
:precision="2"
|
size="small"
|
style="width: 90px"
|
@change="(val: number) => updateFieldValue(record, 'leaveDeduction', val)"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'actualSalary'">
|
<span class="font-bold text-green-600">{{ record.actualSalary?.toFixed(2) }}</span>
|
</template>
|
<template v-else-if="column.dataIndex === 'workDays'">
|
<InputNumber
|
v-model:value="record.workDays"
|
:min="0"
|
:max="31"
|
:precision="0"
|
size="small"
|
style="width: 70px"
|
/>
|
</template>
|
<template v-else-if="column.dataIndex === 'overtimeHours'">
|
<InputNumber
|
v-model:value="record.overtimeHours"
|
:min="0"
|
:precision="1"
|
size="small"
|
style="width: 80px"
|
/>
|
</template>
|
</template>
|
</Table>
|
|
<div class="flex justify-end gap-2" v-if="previewData.length > 0">
|
<a-button @click="handleCancel">取消</a-button>
|
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
保存核算
|
</a-button>
|
</div>
|
</Modal>
|
|
<Grid table-title="薪酬核算列表">
|
<template #toolbar-tools>
|
<TableAction
|
:actions="[
|
{
|
label: '核算',
|
type: 'primary',
|
icon: ACTION_ICON.EDIT,
|
auth: ['hrm:salary-calculation:calculate'],
|
onClick: openCalculateModal,
|
},
|
{
|
label: '批量确认',
|
type: 'primary',
|
auth: ['hrm:salary-calculation:confirm'],
|
onClick: handleBatchConfirm,
|
},
|
{
|
label: $t('ui.actionTitle.export'),
|
type: 'primary',
|
icon: ACTION_ICON.DOWNLOAD,
|
auth: ['hrm:salary-calculation:export'],
|
onClick: handleExport,
|
},
|
]"
|
/>
|
</template>
|
<template #status="{ row }">
|
<Tag :color="row.status === 0 ? 'warning' : row.status === 10 ? 'success' : 'processing'">
|
{{ getDictLabel(DICT_TYPE.HRM_SALARY_STATUS, row.status) }}
|
</Tag>
|
</template>
|
<template #actions="{ row }">
|
<TableAction
|
:actions="[
|
{
|
label: '确认',
|
type: 'link',
|
auth: ['hrm:salary-calculation:confirm'],
|
ifShow: row.status === 0,
|
onClick: handleConfirm.bind(null, row),
|
},
|
]"
|
/>
|
</template>
|
</Grid>
|
</Page>
|
</template>
|