Files
klp-oa/klp-ui/src/views/hrm/payroll/index.vue

632 lines
24 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="hrm-page">
<section class="stats-grid">
<el-card class="metal-panel kpi-card" shadow="hover">
<div class="kpi-title">薪酬方案数</div>
<div class="kpi-value">{{ payPlanList.length }}</div>
<div class="kpi-sub">启用 {{ payPlanList.filter(i => i.status === 'active').length }} </div>
</el-card>
<el-card class="metal-panel kpi-card" shadow="hover">
<div class="kpi-title">薪酬批次数</div>
<div class="kpi-value">{{ payRunList.length }}</div>
<div class="kpi-sub">审批中 {{ payRunList.filter(i => i.status === 'pending').length }} </div>
</el-card>
<el-card class="metal-panel kpi-card" shadow="hover">
<div class="kpi-title">工资条总额(应发)</div>
<div class="kpi-value">{{ formatNumber(payslipList.reduce((s, i) => s + (Number(i.amountGross) || 0), 0)) }}</div>
<div class="kpi-sub">实发 {{ formatNumber(payslipList.reduce((s, i) => s + (Number(i.amountNet) || 0), 0)) }}</div>
</el-card>
<el-card class="metal-panel kpi-card" shadow="hover">
<div class="kpi-title">指标快照</div>
<div class="kpi-value">{{ statList.length }}</div>
<div class="kpi-sub">最新日期{{ latestStatDate || '-' }}</div>
</el-card>
</section>
<section class="panel-grid triple">
<el-card class="metal-panel" shadow="hover">
<div slot="header" class="panel-header">
<span>薪酬方案</span>
<div class="actions-inline">
<el-select v-model="payPlanQuery.status" size="mini" placeholder="状态" clearable style="width:120px" @change="loadPayPlan">
<el-option label="启用" value="active" />
<el-option label="草稿/停用" value="inactive" />
</el-select>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openPlanDialog()">新增</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadPayPlan">刷新</el-button>
</div>
</div>
<el-table :data="payPlanList" v-loading="payPlanLoading" height="320" stripe>
<el-table-column label="方案" prop="planName" min-width="140" />
<el-table-column label="币种" prop="currency" min-width="90" />
<el-table-column label="状态" prop="status" min-width="100">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="描述" prop="remark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="openPlanDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="delPlan(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="metal-panel" shadow="hover">
<div slot="header" class="panel-header">
<span>薪酬批次</span>
<div class="actions-inline">
<el-select v-model="payRunQuery.status" size="mini" placeholder="状态" clearable style="width:120px" @change="loadPayRun">
<el-option label="审批中" value="pending" />
<el-option label="已完成" value="approved" />
</el-select>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openRunDialog()">新增</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadPayRun">刷新</el-button>
</div>
</div>
<el-table :data="payRunList" v-loading="payRunLoading" height="320" stripe>
<el-table-column label="批次" prop="runName" min-width="140" />
<el-table-column label="周期" prop="period" min-width="120" />
<el-table-column label="状态" prop="status" min-width="100">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="openRunDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click="delRun(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="metal-panel" shadow="hover">
<div slot="header" class="panel-header">
<span>工资条 & 指标</span>
<div class="actions-inline">
<el-input
v-model="payslipQuery.batchNo"
placeholder="批次编号"
size="mini"
style="width: 150px"
clearable
@keyup.enter.native="loadPayslip"
/>
<el-input
v-model="payslipQuery.empName"
placeholder="员工姓名/工号"
size="mini"
style="width: 150px"
clearable
@keyup.enter.native="loadPayslip"
/>
<el-button size="mini" type="primary" @click="loadPayslip">查询</el-button>
<el-button size="mini" type="primary" icon="el-icon-plus" @click="openPayslipDialog()">新增</el-button>
<el-button size="mini" icon="el-icon-download" :loading="exportLoading" @click="exportPayslip">导出</el-button>
</div>
</div>
<div class="dual-tables">
<div class="table-half">
<div class="table-title">工资条</div>
<el-table :data="payslipList" v-loading="payslipLoading" height="230" stripe @row-click="openPayslipDetail">
<el-table-column label="员工" prop="empName" min-width="120" />
<el-table-column label="应发" prop="amountGross" min-width="100">
<template slot-scope="scope">{{ formatNumber(scope.row.amountGross) }}</template>
</el-table-column>
<el-table-column label="实发" prop="amountNet" min-width="100">
<template slot-scope="scope">{{ formatNumber(scope.row.amountNet) }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" min-width="90">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)" size="mini">{{ scope.row.status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click.stop="openPayslipDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click.stop="delPayslipRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="table-half">
<div class="table-title">指标快照</div>
<div class="actions-inline" style="margin-bottom:6px">
<el-select v-model="statQuery.statType" size="mini" placeholder="类型" clearable style="width:140px" @change="loadStatSnapshot">
<el-option label="工资成本" value="cost" />
<el-option label="人均成本" value="per_capita_cost" />
<el-option label="奖金" value="bonus" />
</el-select>
</div>
<el-table :data="statList" v-loading="statLoading" height="200" stripe>
<el-table-column label="类型" prop="statType" min-width="120" />
<el-table-column label="维度" prop="dimension" min-width="120" show-overflow-tooltip />
<el-table-column label="数值" prop="value" min-width="100" />
<el-table-column label="日期" prop="statDate" min-width="120" />
</el-table>
</div>
</div>
</el-card>
</section>
<el-dialog title="工资条详情" :visible.sync="payslipDetailVisible" width="520px" append-to-body>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="员工">{{ payslipDetail.empName || payslipDetail.empId }}</el-descriptions-item>
<el-descriptions-item label="批次">{{ payslipDetail.batchNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="应发">{{ formatNumber(payslipDetail.amountGross) }}</el-descriptions-item>
<el-descriptions-item label="实发">{{ formatNumber(payslipDetail.amountNet) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ payslipDetail.status || '-' }}</el-descriptions-item>
<el-descriptions-item label="周期">{{ payslipDetail.period || '-' }}</el-descriptions-item>
<el-descriptions-item label="发放日期">{{ payslipDetail.payDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ payslipDetail.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<div slot="footer" class="dialog-footer">
<el-button @click="payslipDetailVisible = false">关闭</el-button>
</div>
</el-dialog>
<!-- 方案弹窗 -->
<el-dialog title="薪酬方案" :visible.sync="planDialogVisible" width="480px" append-to-body>
<el-form ref="planFormRef" :model="planForm" :rules="planRules" label-width="100px" size="small">
<el-form-item label="方案名称" prop="planName">
<el-input v-model="planForm.planName" />
</el-form-item>
<el-form-item label="币种">
<el-input v-model="planForm.currency" placeholder="如CNY" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="planForm.status" placeholder="选择状态" style="width: 100%">
<el-option label="启用" value="active" />
<el-option label="停用/草稿" value="inactive" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="planForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="planDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="planSubmitting" @click="submitPlan">保存</el-button>
</div>
</el-dialog>
<!-- 批次弹窗 -->
<el-dialog title="薪酬批次" :visible.sync="runDialogVisible" width="480px" append-to-body>
<el-form ref="runFormRef" :model="runForm" :rules="runRules" label-width="100px" size="small">
<el-form-item label="批次名称" prop="runName">
<el-input v-model="runForm.runName" />
</el-form-item>
<el-form-item label="核算周期" prop="period">
<el-input v-model="runForm.period" placeholder="如 2024-08" />
</el-form-item>
<el-form-item label="关联方案" prop="planId">
<el-select v-model="runForm.planId" placeholder="选择方案" filterable style="width: 100%">
<el-option v-for="plan in payPlanList" :key="plan.planId" :label="plan.planName" :value="plan.planId" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="runForm.status" placeholder="选择状态" style="width: 100%">
<el-option label="审批中" value="pending" />
<el-option label="已完成" value="approved" />
<el-option label="草稿" value="draft" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="runForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="runDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="runSubmitting" @click="submitRun">保存</el-button>
</div>
</el-dialog>
<!-- 工资条弹窗 -->
<el-dialog title="工资条" :visible.sync="payslipDialogVisible" width="520px" append-to-body>
<el-form ref="payslipFormRef" :model="payslipForm" :rules="payslipRules" label-width="110px" size="small">
<el-form-item label="员工" prop="empId">
<el-select v-model="payslipForm.empId" filterable placeholder="选择员工" style="width: 100%" :loading="employeeLoading">
<el-option v-for="emp in employeeOptions" :key="emp.empId" :label="`${emp.empName || emp.empCode || emp.empId}`" :value="emp.empId" />
</el-select>
</el-form-item>
<el-form-item label="批次号" prop="batchNo">
<el-input v-model="payslipForm.batchNo" />
</el-form-item>
<el-form-item label="应发金额" prop="amountGross">
<el-input-number v-model="payslipForm.amountGross" :min="0" :step="100" style="width:100%" />
</el-form-item>
<el-form-item label="实发金额" prop="amountNet">
<el-input-number v-model="payslipForm.amountNet" :min="0" :step="100" style="width:100%" />
</el-form-item>
<el-form-item label="周期" prop="period">
<el-input v-model="payslipForm.period" placeholder="如 2024-08" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="payslipForm.status" placeholder="选择状态" style="width: 100%">
<el-option label="待发" value="pending" />
<el-option label="已发" value="paid" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="payslipForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="payslipDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="payslipSubmitting" @click="submitPayslip">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listPayPlan,
getPayPlan,
addPayPlan,
updatePayPlan,
delPayPlan,
listPayRun,
getPayRun,
addPayRun,
updatePayRun,
delPayRun,
listPayslip,
getPayslip,
addPayslip,
updatePayslip,
delPayslip,
listEmployee,
listStatSnapshot
} from '@/api/hrm'
export default {
name: 'HrmPayroll',
data() {
return {
payPlanList: [],
payPlanLoading: false,
payPlanQuery: { status: undefined },
payRunList: [],
payRunLoading: false,
payRunQuery: { status: undefined },
payslipList: [],
payslipLoading: false,
payslipQuery: { batchNo: '', empName: '' },
statList: [],
statLoading: false,
statQuery: { statType: undefined },
payslipDetailVisible: false,
payslipDetail: {},
employeeOptions: [],
employeeLoading: false,
exportLoading: false,
planDialogVisible: false,
planSubmitting: false,
planForm: {},
planRules: { planName: [{ required: true, message: '请输入方案名称', trigger: 'blur' }] },
runDialogVisible: false,
runSubmitting: false,
runForm: {},
runRules: {
runName: [{ required: true, message: '请输入批次名称', trigger: 'blur' }],
period: [{ required: true, message: '请输入核算周期', trigger: 'blur' }],
planId: [{ required: true, message: '请选择方案', trigger: 'change' }]
},
payslipDialogVisible: false,
payslipSubmitting: false,
payslipForm: {},
payslipRules: {
empId: [{ required: true, message: '请输入员工ID', trigger: 'blur' }],
amountGross: [{ required: true, message: '请输入应发金额', trigger: 'blur' }],
amountNet: [{ required: true, message: '请输入实发金额', trigger: 'blur' }],
batchNo: [{ required: true, message: '请输入批次号', trigger: 'blur' }]
}
}
},
computed: {
latestStatDate() {
const dates = (this.statList || []).map(i => i.statDate).filter(Boolean)
if (!dates.length) return ''
return dates.sort().pop()
}
},
created() {
this.loadPayPlan()
this.loadPayRun()
this.loadPayslip()
this.loadStatSnapshot()
this.loadEmployees()
},
methods: {
statusType(status) {
if (!status) return 'info'
const map = { pending: 'warning', draft: 'info', approved: 'success', rejected: 'danger', active: 'success', inactive: 'info' }
return map[status] || 'info'
},
formatNumber(num) {
if (num === null || num === undefined || isNaN(num)) return '0'
return Number(num).toLocaleString(undefined, { maximumFractionDigits: 2 })
},
loadPayPlan() {
this.payPlanLoading = true
listPayPlan({ pageNum: 1, pageSize: 50, status: this.payPlanQuery.status })
.then(res => {
this.payPlanList = res.rows || []
})
.finally(() => {
this.payPlanLoading = false
})
},
loadPayRun() {
this.payRunLoading = true
listPayRun({ pageNum: 1, pageSize: 50, status: this.payRunQuery.status })
.then(res => {
this.payRunList = res.rows || []
})
.finally(() => {
this.payRunLoading = false
})
},
loadPayslip() {
this.payslipLoading = true
listPayslip({ pageNum: 1, pageSize: 50, batchNo: this.payslipQuery.batchNo, empName: this.payslipQuery.empName })
.then(res => {
this.payslipList = res.rows || []
})
.finally(() => {
this.payslipLoading = false
})
},
loadStatSnapshot() {
this.statLoading = true
listStatSnapshot({ pageNum: 1, pageSize: 50, statType: this.statQuery.statType })
.then(res => {
this.statList = res.rows || []
})
.finally(() => {
this.statLoading = false
})
},
loadEmployees() {
this.employeeLoading = true
listEmployee({ pageNum: 1, pageSize: 500 })
.then(res => {
this.employeeOptions = res.rows || []
})
.finally(() => {
this.employeeLoading = false
})
},
openPayslipDetail(row) {
this.payslipDetail = { ...row }
this.payslipDetailVisible = true
},
// 方案 CRUD
openPlanDialog(row) {
this.planForm = row ? { ...row } : { planName: '', currency: 'CNY', status: 'active', remark: '' }
this.planDialogVisible = true
this.$nextTick(() => this.$refs.planFormRef && this.$refs.planFormRef.clearValidate())
},
submitPlan() {
this.$refs.planFormRef.validate(valid => {
if (!valid) return
this.planSubmitting = true
const api = this.planForm.planId ? updatePayPlan : addPayPlan
api(this.planForm)
.then(() => {
this.$message.success('已保存')
this.planDialogVisible = false
this.loadPayPlan()
})
.finally(() => {
this.planSubmitting = false
})
})
},
delPlan(row) {
this.$confirm('确认删除该方案吗?', '提示', { type: 'warning' }).then(() => {
delPayPlan(row.planId).then(() => {
this.$message.success('已删除')
this.loadPayPlan()
})
})
},
// 批次 CRUD
openRunDialog(row) {
this.runForm = row
? { ...row }
: { runName: '', period: '', planId: this.payPlanList[0]?.planId, status: 'pending', remark: '' }
this.runDialogVisible = true
this.$nextTick(() => this.$refs.runFormRef && this.$refs.runFormRef.clearValidate())
},
submitRun() {
this.$refs.runFormRef.validate(valid => {
if (!valid) return
this.runSubmitting = true
const api = this.runForm.runId ? updatePayRun : addPayRun
api(this.runForm)
.then(() => {
this.$message.success('已保存')
this.runDialogVisible = false
this.loadPayRun()
})
.finally(() => {
this.runSubmitting = false
})
})
},
delRun(row) {
this.$confirm('确认删除该批次吗?', '提示', { type: 'warning' }).then(() => {
delPayRun(row.runId).then(() => {
this.$message.success('已删除')
this.loadPayRun()
})
})
},
// 工资条 CRUD
openPayslipDialog(row) {
this.payslipForm = row
? { ...row }
: {
empId: '',
batchNo: '',
amountGross: 0,
amountNet: 0,
period: '',
status: 'pending',
remark: ''
}
this.payslipDialogVisible = true
this.$nextTick(() => this.$refs.payslipFormRef && this.$refs.payslipFormRef.clearValidate())
},
submitPayslip() {
this.$refs.payslipFormRef.validate(valid => {
if (!valid) return
this.payslipSubmitting = true
const api = this.payslipForm.slipId ? updatePayslip : addPayslip
api(this.payslipForm)
.then(() => {
this.$message.success('已保存')
this.payslipDialogVisible = false
this.loadPayslip()
})
.finally(() => {
this.payslipSubmitting = false
})
})
},
delPayslipRow(row) {
this.$confirm('确认删除该工资条吗?', '提示', { type: 'warning' }).then(() => {
delPayslip(row.slipId).then(() => {
this.$message.success('已删除')
this.loadPayslip()
})
})
},
exportPayslip() {
if (!this.payslipList.length) {
this.$message.info('暂无可导出的数据')
return
}
this.exportLoading = true
try {
const headers = ['员工', '批次号', '应发', '实发', '周期', '状态', '发放日期', '备注']
const rows = this.payslipList.map(i => [
i.empName || i.empId || '',
i.batchNo || '',
i.amountGross || '',
i.amountNet || '',
i.period || '',
i.status || '',
i.payDate || '',
(i.remark || '').replace(/\r?\n/g, ' ')
])
const tableRows = [headers, ...rows]
.map(
r =>
'<tr>' +
r
.map(v => `<td style="mso-number-format:'\\@';">${String(v).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</td>`)
.join('') +
'</tr>'
)
.join('')
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta charset="UTF-8"></head><body><table>${tableRows}</table></body></html>`
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `payslips_${Date.now()}.xls`
a.click()
URL.revokeObjectURL(url)
} finally {
this.exportLoading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.hrm-page {
padding: 16px 20px 32px;
background: #f8f9fb;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 12px;
}
.panel-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.metal-panel {
border: 1px solid #d7d9df;
border-radius: 10px;
background: #fff;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: #303133;
}
.actions-inline {
display: flex;
gap: 8px;
align-items: center;
}
.kpi-card {
.kpi-title {
font-size: 13px;
color: #7a7d85;
margin-bottom: 4px;
}
.kpi-value {
font-size: 22px;
font-weight: 700;
color: #303133;
line-height: 1.3;
}
.kpi-sub {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
}
.dual-tables {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.table-half {
border: 1px dashed #e6e8ed;
border-radius: 8px;
padding: 8px;
}
.table-title {
font-weight: 600;
margin-bottom: 6px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.panel-grid {
grid-template-columns: 1fr;
}
.dual-tables {
grid-template-columns: 1fr;
}
}
</style>