Files
klp-oa/klp-ui/src/views/perf/index/SalaryPanel.vue
2026-07-10 17:25:23 +08:00

511 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="salary-panel">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="query.employeeId" size="small" placeholder="员工ID" clearable style="width: 140px" @keyup.enter.native="loadList" />
<!-- 状态筛选当前阶段为保证统计页能直接聚合展示临时停用 status 参数筛选 -->
<!-- <el-select v-model="query.status" size="small" placeholder="状态" clearable style="width: 110px" @change="loadList">
<el-option label="草稿" :value="'0'" />
<el-option label="已确认" :value="'1'" />
<el-option label="已归档" :value="'2'" />
</el-select> -->
<el-button size="small" type="primary" icon="el-icon-search" @click="loadList">查询</el-button>
</div>
<div class="toolbar-right">
<el-button size="small" type="primary" icon="el-icon-plus" @click="handleAdd">新增</el-button>
<el-button size="small" type="success" icon="el-icon-s-operation" :loading="generating" @click="handleBatchGenerate">一键生成</el-button>
</div>
</div>
<!-- 表格 -->
<div class="table-wrap">
<el-table :data="list" stripe size="small" v-loading="loading" empty-text="暂无薪资记录" class="inline-edit-table" border>
<!-- 只读列 -->
<!-- <el-table-column prop="employeeId" label="员工ID" width="90" /> -->
<!-- 状态列同上临时停用 -->
<!-- <el-table-column prop="status" label="状态" width="70" align="center">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column> -->
<!-- 薪资基数 -->
<el-table-column label="底薪" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.baseSalary" class="cell-input cell-input--base" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="绩效基数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.perfBase" class="cell-input cell-input--base" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 考核得分 -->
<el-table-column label="考核得分" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.perfScore" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 系数 -->
<el-table-column label="绩效系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.perfCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="车间系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.deptCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="岗位系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.posCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="固定系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.fixedCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="调整系数" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.adjCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="手动调整系数" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.adjCoeffManual" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="总系数" width="80" align="right"><template slot-scope="s">
<input v-model="s.row.totalCoeff" class="cell-input cell-input--coeff" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 绩效工资 -->
<el-table-column label="绩效工资" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.perfWage" class="cell-input cell-input--amount" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 动态扣款列 -->
<el-table-column
v-for="item in deductionItems" :key="'ded-'+item.itemName"
:label="item.itemName" width="100" align="right"
><template slot-scope="s">
<input v-model="s.row._d[item.itemName]" class="cell-input cell-input--deduct" @change="onDeductChange(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="扣款合计" width="90" align="right">
<template slot-scope="scope"><span class="cell-readonly cell-readonly--deduct">{{ scope.row._deductionsTotal }}</span></template>
</el-table-column>
<!-- 动态奖励列 -->
<el-table-column
v-for="item in bonusItems" :key="'bon-'+item.itemName"
:label="item.itemName" width="100" align="right"
><template slot-scope="s">
<input v-model="s.row._b[item.itemName]" class="cell-input cell-input--bonus" @change="onBonusChange(s.row); markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="奖励合计" width="90" align="right">
<template slot-scope="scope"><span class="cell-readonly cell-readonly--bonus">{{ scope.row._bonusesTotal }}</span></template>
</el-table-column>
<!-- 金额 -->
<el-table-column label="其他金额" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.otherAmount" class="cell-input cell-input--amount" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="实领薪资" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.actualSalary" class="cell-input cell-input--amount" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 参考值 -->
<el-table-column label="固薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.fixedSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="调薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.adjSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="包薪每班参考" width="100" align="right"><template slot-scope="s">
<input v-model="s.row.baoPerShiftRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="包薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.baoSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<el-table-column label="总薪参考" width="90" align="right"><template slot-scope="s">
<input v-model="s.row.totalSalaryRef" class="cell-input cell-input--ref" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 备注 -->
<el-table-column label="备注" min-width="140"><template slot-scope="s">
<input v-model="s.row.remark" class="cell-input cell-input--remark" @change="markDirty(s.row)" />
</template></el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="90" align="center" fixed="right">
<template slot-scope="scope">
<el-button v-if="scope.row._dirty" type="text" size="mini" icon="el-icon-check" :loading="scope.row._saving" class="btn-save" @click="saveRow(scope.row)">保存</el-button>
<el-button type="text" size="mini" icon="el-icon-delete" @click="handleDel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-wrapper">
<el-pagination
:current-page="query.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size="query.pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script>
import { listPerfSalary, addPerfSalary, updatePerfSalary, delPerfSalary } from '@/api/perf/salary'
import { listEmployeeInfo } from '@/api/wms/employeeInfo'
import { listPerfDeptItemConfig } from '@/api/perf/deptItemConfig'
export default {
name: 'SalaryPanel',
props: {
deptId: { type: [Number, String], default: null },
deptName: { type: String, default: '' },
period: { type: String, default: '' }
},
data() {
return {
loading: false,
list: [],
total: 0,
query: {
pageNum: 1,
pageSize: 10,
employeeId: '',
// status: ''
},
deductionItems: [],
bonusItems: [],
generating: false
}
},
watch: {
deptId: { immediate: true, handler: 'onDeptChange' },
period: 'onDeptChange'
},
methods: {
fmt(val) {
return val != null ? val : '-'
},
// statusType(status) {
// return { '0': 'info', '1': 'warning', '2': 'success' }[status] || 'info'
// },
// statusLabel(status) {
// return { '0': '草稿', '1': '已确认', '2': '已归档' }[status] || '草稿'
// },
onDeptChange() {
this.loadItemConfig()
this.loadList()
},
loadItemConfig() {
if (!this.deptId || !this.period) return
listPerfDeptItemConfig({ deptId: this.deptId, month: this.period + '-01' }).then(res => {
const rows = res.rows || []
this.deductionItems = rows.filter(r => r.itemType === 1 && r.isEnabled === 1)
this.bonusItems = rows.filter(r => r.itemType === 2 && r.isEnabled === 1)
}).catch(() => {
this.deductionItems = []
this.bonusItems = []
})
},
/* 解析扣款/奖励 JSON 到行对象 */
parseRowItems(row) {
if (!row._d) {
const d = {}
try { Object.assign(d, JSON.parse(row.deductionsJson || '{}')) } catch {}
this.deductionItems.forEach(item => { if (!(item.itemName in d)) d[item.itemName] = '' })
this.$set(row, '_d', d)
}
if (!row._b) {
const b = {}
try { Object.assign(b, JSON.parse(row.bonusesJson || '{}')) } catch {}
this.bonusItems.forEach(item => { if (!(item.itemName in b)) b[item.itemName] = '' })
this.$set(row, '_b', b)
}
this.recalcTotals(row)
},
recalcTotals(row) {
const sumD = Object.values(row._d || {}).reduce((s, v) => s + (parseFloat(v) || 0), 0)
const sumB = Object.values(row._b || {}).reduce((s, v) => s + (parseFloat(v) || 0), 0)
this.$set(row, '_deductionsTotal', sumD)
this.$set(row, '_bonusesTotal', sumB)
},
onDeductChange(row) { this.recalcTotals(row) },
onBonusChange(row) { this.recalcTotals(row) },
markDirty(row) { this.$set(row, '_dirty', true) },
loadList() {
if (!this.deptId || !this.period) return
this.loading = true
const params = {
pageNum: this.query.pageNum,
pageSize: this.query.pageSize,
deptId: this.deptId,
period: this.period,
employeeId: this.query.employeeId || undefined
}
listPerfSalary(params).then(res => {
this.list = (res.rows || []).map(r => {
this.$set(r, '_dirty', false)
this.$set(r, '_saving', false)
this.parseRowItems(r)
return r
})
this.total = res.total || 0
}).finally(() => {
this.loading = false
})
},
handleSizeChange(val) {
this.query.pageSize = val
this.loadList()
},
handlePageChange(val) {
this.query.pageNum = val
this.loadList()
},
handleAdd() {
const row = {
employeeId: '',
deptId: this.deptId,
period: this.period,
baseSalary: '',
perfBase: '',
perfScore: '',
perfCoeff: '',
deptCoeff: '',
posCoeff: '',
fixedCoeff: '',
adjCoeff: '',
adjCoeffManual: '',
totalCoeff: '',
perfWage: '',
deductionsJson: '{}',
bonusesJson: '{}',
otherAmount: '',
actualSalary: '',
fixedSalaryRef: '',
adjSalaryRef: '',
baoPerShiftRef: '',
baoSalaryRef: '',
totalSalaryRef: '',
status: '0',
remark: '',
_dirty: true,
_saving: false,
_d: {},
_b: {},
_deductionsTotal: 0,
_bonusesTotal: 0
}
this.deductionItems.forEach(item => { row._d[item.itemName] = '' })
this.bonusItems.forEach(item => { row._b[item.itemName] = '' })
this.list.unshift(row)
},
saveRow(row) {
this.$set(row, '_saving', true)
const data = { ...row }
// 序列化扣款/奖励 JSON
data.deductionsJson = JSON.stringify(row._d || {})
data.bonusesJson = JSON.stringify(row._b || {})
data.deductionsTotal = row._deductionsTotal
data.bonusesTotal = row._bonusesTotal
// 清理内部字段
delete data._d; delete data._b; delete data._dirty; delete data._saving
delete data._deductionsTotal; delete data._bonusesTotal
const api = data.id ? updatePerfSalary : addPerfSalary
api(data).then(res => {
// 乐观更新:如果是新增,用返回值更新 id
if (!data.id && res.data) {
this.$set(row, 'id', res.data.id || res.data)
}
this.$set(row, '_dirty', false)
this.$message.success('保存成功')
}).catch(() => {
this.$message.error('保存失败')
this.loadList() // 失败则重新加载以回滚
}).finally(() => {
this.$set(row, '_saving', false)
})
},
handleDel(row) {
if (!row.id) {
// 未保存的新行直接移除
const idx = this.list.indexOf(row)
if (idx >= 0) this.list.splice(idx, 1)
return
}
this.$confirm(`确认删除该薪资记录吗?`, '提示', { type: 'warning' }).then(() => {
delPerfSalary(row.id).then(() => {
this.$message.success('删除成功')
this.loadList()
})
}).catch(() => {})
},
handleBatchGenerate() {
if (!this.deptName) {
this.$message.warning('缺少部门名称')
return
}
this.$confirm(`确认根据"${this.deptName}"下的在职员工,一键生成本月薪资记录吗?`, '提示', { type: 'info' }).then(() => {
this.generating = true
listEmployeeInfo({ dept: this.deptName, isLeave: 0, pageSize: 9999 }).then(empRes => {
const employees = empRes.rows || []
if (employees.length === 0) {
this.$message.warning('该部门下暂无在职员工')
return
}
listPerfSalary({ deptId: this.deptId, period: this.period, pageSize: 9999 }).then(salaryRes => {
const existing = new Set((salaryRes.rows || []).map(r => String(r.employeeId)))
const toInsert = employees.filter(e => !existing.has(String(e.employeeId) || String(e.infoId)))
if (toInsert.length === 0) {
this.$message.info('该部门下所有在职员工已有本月薪资记录')
return
}
const emptyJson = JSON.stringify({})
const tasks = toInsert.map(emp => addPerfSalary({
employeeId: emp.employeeId || emp.infoId || '',
deptId: this.deptId,
period: this.period,
baseSalary: emp.baseSalary || 0,
perfBase: emp.perfBase || 0,
posCoeff: emp.posCoeffDefault || 0,
perfScore: 0,
perfCoeff: 0,
deptCoeff: 0,
fixedCoeff: 0,
adjCoeff: 0,
adjCoeffManual: 0,
totalCoeff: 0,
perfWage: 0,
deductionsJson: emptyJson,
deductionsTotal: 0,
bonusesJson: emptyJson,
bonusesTotal: 0,
otherAmount: 0,
actualSalary: 0,
fixedSalaryRef: '',
adjSalaryRef: '',
baoPerShiftRef: '',
baoSalaryRef: '',
totalSalaryRef: '',
status: '0',
remark: ''
}))
return Promise.allSettled(tasks)
}).then(results => {
if (!results) return
const ok = results.filter(r => r.status === 'fulfilled').length
const fail = results.filter(r => r.status === 'rejected').length
this.$message.success(`生成完成:成功 ${ok}${fail > 0 ? `,失败 ${fail}` : ''}`)
this.loadList()
})
}).finally(() => {
this.generating = false
})
}).catch(() => {})
}
}
}
</script>
<style lang="scss" scoped>
.salary-panel {
padding: 8px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
flex-shrink: 0;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.table-wrap {
flex: 1;
overflow: auto;
}
::v-deep .inline-edit-table {
border: none;
&::before, &::after { display: none; }
th { border: none; padding: 2px 4px; font-size: 12px; color: #909399; }
td {
border: none;
border-right: 1px solid #ebeef5;
padding: 0;
}
.el-table__body tr:hover td { background-color: #f5f7fa !important; }
.cell { padding: 0; height: 100%; }
}
/* 原生 input */
.cell-input {
width: 100%;
height: 28px;
border: none;
outline: none;
text-align: right;
padding: 0 6px;
font-size: 12px;
color: #303133;
font-family: inherit;
box-sizing: border-box;
display: block;
line-height: 28px;
transition: background-color 0.15s;
&:focus {
background-color: #fff;
box-shadow: 0 0 0 1px #409eff inset;
border-radius: 0;
}
&--base { background-color: #e8f4fd; }
&--coeff { background-color: #fff8e1; }
&--deduct { background-color: #ffebee; }
&--bonus { background-color: #e8f5e9; }
&--amount { background-color: #f3e5f5; }
&--ref { background-color: #f5f5f5; }
&--remark { background-color: #fafafa; text-align: left; }
}
.cell-readonly {
display: block;
text-align: right;
padding: 0 6px;
font-size: 12px;
color: #303133;
font-weight: 500;
line-height: 28px;
height: 28px;
&--deduct { color: #e53935; }
&--bonus { color: #43a047; }
}
.btn-save {
color: #67c23a !important;
}
.pagination-wrapper {
margin-top: 6px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.el-table .el-button + .el-button {
margin-left: 0;
}
</style>