feat: 绩效工资改为手动获取+按考核系数着色

后端:
- 建立批次时绩效工资填0,不自动带出
- 获取绩效工资接口返回 details(grade/coefficient/performanceSalary)
- 无考核记录时系数为1.0(绩效工资=基数全额)

前端:
- 存储获取绩效工资返回的 details 到 perfDetails state
- 绩效工资列按系数着色:A级(1.2)绿色/C级(0.8)橙色/D级(0.6)红色
- B级(1.0)和无考核正常色,0值灰色
- hover显示考核等级和系数
This commit is contained in:
freedakgmail
2026-08-19 07:36:22 +08:00
parent c6fcfd36b8
commit a7f3d5f3b5
2 changed files with 65 additions and 4 deletions
+7 -3
View File
@@ -493,11 +493,11 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
} else if (prevPayslip) { } else if (prevPayslip) {
// 非试用期:优先用上月工资条的基本工资(保持薪资连续性) // 非试用期:优先用上月工资条的基本工资(保持薪资连续性)
baseSalary = prevPayslip.baseSalary baseSalary = prevPayslip.baseSalary
performanceSalary = prevPayslip.performanceSalary || 0 // 绩效工资不自动带出,需手动点"获取绩效工资"按钮
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) { } else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
// 新数据:有工资结构 // 新数据:有工资结构
baseSalary = empBaseSalary baseSalary = empBaseSalary
performanceSalary = empPerformanceSalary // 绩效工资不自动带出,需手动点"获取绩效工资"按钮
} else if (emp.monthlySalary) { } else if (emp.monthlySalary) {
// 旧数据兼容:回退到 monthlySalary // 旧数据兼容:回退到 monthlySalary
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
@@ -515,7 +515,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
performanceSalary = 0 performanceSalary = 0
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) { } else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
baseSalary = empBaseSalary baseSalary = empBaseSalary
performanceSalary = empPerformanceSalary // 绩效工资不自动带出,需手动点"获取绩效工资"按钮
} else if (emp.monthlySalary && mode === 'custom') { } else if (emp.monthlySalary && mode === 'custom') {
// custom 模式旧数据兼容 // custom 模式旧数据兼容
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
@@ -840,11 +840,13 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
let filled = 0 let filled = 0
let totalAmount = 0 let totalAmount = 0
const details: { employeeId: string; grade: string | null; coefficient: number; performanceSalary: number }[] = []
for (const entry of entries) { for (const entry of entries) {
const basePerfSalary = empPerfSalaryMap.get(entry.employeeId) || 0 const basePerfSalary = empPerfSalaryMap.get(entry.employeeId) || 0
if (basePerfSalary <= 0) continue // 绩效工资基数为0则跳过 if (basePerfSalary <= 0) continue // 绩效工资基数为0则跳过
const perfRecord = perfMap.get(entry.employeeId) const perfRecord = perfMap.get(entry.employeeId)
const grade = perfRecord?.grade || null
const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0 const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100 const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
@@ -853,6 +855,7 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
data: { performanceSalary: calculatedPerfSalary }, data: { performanceSalary: calculatedPerfSalary },
}) })
await recalcEntry(orgId, batchId, batch.month, batch.type, entry.id, entry.employeeId) await recalcEntry(orgId, batchId, batch.month, batch.type, entry.id, entry.employeeId)
details.push({ employeeId: entry.employeeId, grade, coefficient, performanceSalary: calculatedPerfSalary })
filled++ filled++
totalAmount += calculatedPerfSalary totalAmount += calculatedPerfSalary
} }
@@ -865,6 +868,7 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res:
data: { data: {
filled, filled,
totalAmount: Math.round(totalAmount * 100) / 100, totalAmount: Math.round(totalAmount * 100) / 100,
details,
message: filled > 0 message: filled > 0
? `已填充 ${filled} 人绩效工资,合计 ¥${Math.round(totalAmount * 100) / 100}${performanceRecords.length > 0 ? `${performanceRecords.length} 人有考核记录)` : '(无考核记录,按系数1.0计算)'}` ? `已填充 ${filled} 人绩效工资,合计 ¥${Math.round(totalAmount * 100) / 100}${performanceRecords.length > 0 ? `${performanceRecords.length} 人有考核记录)` : '(无考核记录,按系数1.0计算)'}`
: '无绩效工资数据(员工未设置绩效工资基数)', : '无绩效工资数据(员工未设置绩效工资基数)',
+58 -1
View File
@@ -489,6 +489,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const [taxDetailFor, setTaxDetailFor] = useState<{ employeeId: string; name: string } | null>(null) const [taxDetailFor, setTaxDetailFor] = useState<{ employeeId: string; name: string } | null>(null)
const [taxDetailData, setTaxDetailData] = useState<any>(null) const [taxDetailData, setTaxDetailData] = useState<any>(null)
const [taxDetailLoading, setTaxDetailLoading] = useState(false) const [taxDetailLoading, setTaxDetailLoading] = useState(false)
const [perfDetails, setPerfDetails] = useState<Map<string, { grade: string | null; coefficient: number }>>(new Map())
const { data: batch, isLoading } = useQuery<any>({ const { data: batch, isLoading } = useQuery<any>({
queryKey: ['batch-detail', batchId], queryKey: ['batch-detail', batchId],
@@ -599,6 +600,13 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
onSuccess: (res: any) => { onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['batches'] })
if (res.data?.details) {
const map = new Map<string, { grade: string | null; coefficient: number }>()
for (const d of res.data.details) {
map.set(d.employeeId, { grade: d.grade, coefficient: d.coefficient })
}
setPerfDetails(map)
}
if (res.data?.filled > 0) { if (res.data?.filled > 0) {
toast.success(res.data.message) toast.success(res.data.message)
} else { } else {
@@ -1229,7 +1237,56 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
})()} })()}
</td> </td>
{renderCell(entry, 'baseSalary')} {renderCell(entry, 'baseSalary')}
{renderCell(entry, 'performanceSalary')} {(() => {
const perfVal = entry.performanceSalary || 0
const perfInfo = perfDetails.get(entry.employeeId)
const coef = perfInfo?.coefficient
const grade = perfInfo?.grade
// 颜色:A(1.2)=绿色, C(0.8)=橙色, D(0.6)=红色, B(1.0)/无=默认
let colorClass = ''
let title = ''
if (coef != null && coef !== 1.0) {
if (coef > 1.0) { colorClass = 'text-green-600'; title = `考核等级 ${grade},系数 ${coef}` }
else if (coef >= 0.8) { colorClass = 'text-orange-600'; title = `考核等级 ${grade},系数 ${coef}` }
else { colorClass = 'text-red-600'; title = `考核等级 ${grade},系数 ${coef}` }
} else if (coef === 1.0 && grade) {
title = `考核等级 ${grade},系数 1.0`
}
const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === 'performanceSalary'
const canEdit = !isArchived && editableFields.includes('performanceSalary')
if (isEditing) {
return (
<td className="py-1 px-1 text-right" key="performanceSalary">
<input
type="text"
inputMode="decimal"
autoFocus
className="w-24 text-right border-b-2 border-primary bg-transparent px-1 py-0.5 text-xs focus:outline-none [appearance:none]"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
</td>
)
}
return (
<td
key="performanceSalary"
className={`py-2 px-2 text-right ${canEdit ? 'cursor-text' : ''}`}
onClick={() => canEdit && startEdit(entry.employeeId, 'performanceSalary', perfVal)}
title={title}
>
{canEdit ? (
<span className={`border-b border-dashed border-gray-300 hover:border-primary ${colorClass} ${perfVal === 0 ? 'text-gray-300' : ''}`}>
{fmt(perfVal)}
</span>
) : (
<span className={`${colorClass} ${perfVal === 0 ? 'text-gray-300' : ''}`}>{fmt(perfVal)}</span>
)}
</td>
)
})()}
{renderCell(entry, 'overtimePay')} {renderCell(entry, 'overtimePay')}
{renderCell(entry, 'allowance')} {renderCell(entry, 'allowance')}
{renderCell(entry, 'bonus')} {renderCell(entry, 'bonus')}