feat: 发薪批次表格动态化 - 根据薪酬模板动态生成列

- schema: BatchEntry 增加 extraItems Json 字段存储自定义模板项
- calcBatchEntry: 新增 extraItems 参数,合并自定义 INPUT 项取值
- 后端编辑接口: 动态校验自定义字段,预置字段走固定列,自定义走 extraItems
- 所有 calcBatchEntry 调用处传入并写回 extraItems
- 前端表头/单元格从 templateItems 动态生成,排除 netPay 重复列
- 前端 saveEdit 动态化: 预置字段走固定列,自定义字段走 extraItems
- renderCell 支持从 extraItems 取值
- 表格列左右锁定: 员工/合同类型左锁,实发/递延/风险/操作右锁
- 合理列宽 + 横向滚动 + hover 效果
This commit is contained in:
freedakgmail
2026-08-19 09:15:54 +08:00
parent 64ae633857
commit 20f920686e
6 changed files with 171 additions and 121 deletions
+98 -92
View File
@@ -750,6 +750,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
: ['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
})()
// 预置字段集合(固定列存储)
const PRESET_FIELDS = new Set(['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'socialOrg', 'housingEmp', 'housingOrg', 'positionSalary', 'senioritySalary', 'transportAllowance', 'mealAllowance', 'housingAllowance', 'communicationAllowance', 'otherDeduction'])
// 点击单元格进入编辑
const startEdit = (employeeId: string, field: string, currentValue: number) => {
if (isArchived || !editableFields.includes(field)) return
@@ -765,15 +768,20 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const entry = batch.entries.find((e: any) => e.employeeId === employeeId)
if (!entry) { setEditCell(null); return }
const data: any = {}
// 输入项字段一起提交
const inputFields = isBonus ? ['bonus'] : ['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus']
inputFields.forEach(f => {
data[f] = f === field ? numValue : entry[f]
})
// 社保公积金字段:编辑哪个提交哪个
const socialFields = ['socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
const socialFields = ['socialEmp', 'socialOrg', 'housingEmp', 'housingOrg']
if (socialFields.includes(field)) {
data[field] = numValue
} else if (PRESET_FIELDS.has(field)) {
// 预置输入项字段一起提交
const inputFields = isBonus ? ['bonus'] : (templateItems || []).filter((t: any) => t.type === 'INPUT' && t.isEditable && PRESET_FIELDS.has(t.code)).map((t: any) => t.code)
inputFields.forEach(f => {
data[f] = f === field ? numValue : entry[f]
})
} else {
// 自定义字段走 extraItems
const existingExtra = entry.extraItems || {}
data.extraItems = { ...existingExtra, [field]: numValue }
}
updateEntryMutation.mutate({ employeeId, data })
setEditCell(null)
@@ -793,7 +801,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === field
const canEdit = !isArchived && editableFields.includes(field)
const value = entry[field] || 0
const value = PRESET_FIELDS.has(field) ? (entry[field] || 0) : ((entry.extraItems || {})[field] || 0)
const displayValue = field === 'deduction' && value > 0 ? '-' + fmt(value) : fmt(value)
if (isEditing) {
@@ -1226,33 +1234,28 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
{/* 人员表格 */}
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<div className="overflow-x-auto" style={{ maxWidth: '100%' }}>
<table className="text-sm" style={{ borderCollapse: 'separate', borderSpacing: 0, minWidth: 1200 }}>
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
{isBonus && <th className="py-2 px-2 text-right"></th>}
{!isBonus && <th className="py-2 px-2 text-right"></th>}
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2 text-right">()</th>
<th className="py-2 px-2 text-right">()</th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2 text-right text-gray-500"></th>
<th className="py-2 px-2"></th>
{!isArchived && <th className="py-2 px-2"></th>}
<tr className="text-left text-xs text-gray-500">
<th className="py-2 px-3 whitespace-nowrap sticky left-0 z-20 bg-white border-r border-gray-200" style={{ width: 140 }}></th>
<th className="py-2 px-2 whitespace-nowrap sticky z-20 bg-white border-r border-gray-200" style={{ width: 100, left: 140 }}></th>
{/* 动态生成模板项列(排除 netPay,由固定实发列显示) */}
{(templateItems || []).filter((t: any) => (!isBonus || t.code === 'bonus') && t.code !== 'netPay').map((t: any) => (
<th key={t.code} className={`py-2 px-2 text-right whitespace-nowrap ${t.type === 'CALCULATED' ? 'text-gray-500' : ''}`} style={{ minWidth: 80 }}>
{t.name}
</th>
))}
<th className="py-2 px-2 text-right text-gray-500 whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 110, right: isArchived ? 160 : 210 }}></th>
<th className="py-2 px-2 text-right text-gray-500 whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 110, right: isArchived ? 50 : 100 }}></th>
<th className="py-2 px-2 text-center whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 50, right: isArchived ? 0 : 50 }}></th>
{!isArchived && <th className="py-2 px-2 text-center whitespace-nowrap sticky z-20 bg-white border-l border-gray-200" style={{ width: 50, right: 0 }}></th>}
</tr>
</thead>
<tbody>
{batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => (
<tr key={entry.id} className="border-b last:border-0 hover:bg-gray-25">
<td className="py-2 px-2">
<tr key={entry.id} className="group border-b last:border-0">
<td className="py-2 px-3 sticky left-0 z-10 bg-white group-hover:bg-gray-50 border-r border-gray-200" style={{ width: 140 }}>
<div className="flex flex-col gap-0.5">
<div className="text-xs font-medium">{entry.employee.name}</div>
{entry.employee.idCardNumber && <span className="text-gray-400 text-[11px] font-mono">{entry.employee.idCardNumber}</span>}
@@ -1262,7 +1265,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)}
</div>
</td>
<td className="py-2 px-2">
<td className="py-2 px-2 sticky z-10 bg-white group-hover:bg-gray-50 border-r border-gray-200" style={{ width: 100, left: 140 }}>
{(() => {
const ct = entry.employee.contracts?.[0]?.contractType
const cfg: Record<string, { label: string; style: string }> = {
@@ -1285,75 +1288,78 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)
})()}
</td>
{renderCell(entry, 'baseSalary')}
{(() => {
const perfVal = entry.performanceSalary || 0
const grade = entry.perfGrade || null
const coef = entry.perfCoefficient != null ? entry.perfCoefficient : null
// 颜色:A(1.2)=绿色, C(0.8)=橙色, D(0.6)=红色, B(1.0)/无=默认
let colorClass = ''
if (coef != null && coef !== 1.0) {
if (coef > 1.0) colorClass = 'text-green-600'
else if (coef >= 0.8) colorClass = 'text-orange-600'
else colorClass = 'text-red-600'
}
const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === 'performanceSalary'
const canEdit = !isArchived && editableFields.includes('performanceSalary')
if (isEditing) {
{/* 动态生成模板项单元格(排除 netPay) */}
{(templateItems || []).filter((t: any) => (!isBonus || t.code === 'bonus') && t.code !== 'netPay').map((t: any) => {
// 绩效工资:保留特殊渲染(显示绩效等级)
if (t.code === 'performanceSalary') {
const perfVal = entry.performanceSalary || 0
const grade = entry.perfGrade || null
const coef = entry.perfCoefficient != null ? entry.perfCoefficient : null
let colorClass = ''
if (coef != null && coef !== 1.0) {
if (coef > 1.0) colorClass = 'text-green-600'
else if (coef >= 0.8) colorClass = 'text-orange-600'
else colorClass = 'text-red-600'
}
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 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
key="performanceSalary"
className={`py-1 px-2 text-right ${canEdit ? 'cursor-text' : ''}`}
onClick={() => canEdit && startEdit(entry.employeeId, 'performanceSalary', perfVal)}
>
<div className="flex flex-col items-end leading-tight">
{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>
)}
{grade && (
<span className={`text-[10px] ${colorClass || 'text-gray-400'}`}>
{grade}·{coef != null ? coef.toFixed(1) : '1.0'}
</span>
)}
</div>
</td>
)
}
return (
<td
key="performanceSalary"
className={`py-1 px-2 text-right ${canEdit ? 'cursor-text' : ''}`}
onClick={() => canEdit && startEdit(entry.employeeId, 'performanceSalary', perfVal)}
>
<div className="flex flex-col items-end leading-tight">
{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>
)}
{grade && (
<span className={`text-[10px] ${colorClass || 'text-gray-400'}`}>
{grade}·{coef != null ? coef.toFixed(1) : '1.0'}
</span>
)}
</div>
</td>
)
})()}
{renderCell(entry, 'overtimePay')}
{renderCell(entry, 'allowance')}
{renderCell(entry, 'bonus')}
{renderCell(entry, 'deduction')}
{/* 计算项 - 灰显 */}
<td className="py-2 px-2 text-right font-medium text-gray-600">{fmt(entry.totalPay)}</td>
{renderCell(entry, 'socialEmp')}
{renderCell(entry, 'housingEmp')}
<td className="py-2 px-2 text-right text-gray-500 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">{fmt(entry.tax)}</td>
// 个税:保留点击查看明细
if (t.code === 'tax') {
return (
<td key="tax" className="py-2 px-2 text-right text-gray-500 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">
{fmt(entry.tax)}
</td>
)
}
// 其他项:通用渲染
return renderCell(entry, t.code, t.type === 'CALCULATED' ? 'font-medium text-gray-600' : '')
})}
{/* 实发:最低工资保护触发时高亮显示 */}
<td className={`py-2 px-2 text-right font-bold ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}>
<td className={`py-2 px-2 text-right font-bold sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200 ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} style={{ width: 110, right: isArchived ? 160 : 210 }} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}>
{fmt(entry.netPay)}
{(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1"></span>}
</td>
{/* 递延扣款明细 */}
<td className="py-2 px-2 text-right text-xs">
<td className="py-2 px-2 text-right text-xs sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: 110, right: isArchived ? 50 : 100 }}>
{(() => {
const deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0)
const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 0)
@@ -1366,7 +1372,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
return <span className="text-gray-300"></span>
})()}
</td>
<td className="py-2 px-2">
<td className="py-2 px-2 text-center sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: 50, right: isArchived ? 0 : 50 }}>
{entry.riskWarnings && entry.riskWarnings.length > 0 ? (
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
<AlertTriangle className="w-4 h-4" />
@@ -1380,7 +1386,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)}
</td>
{!isArchived && (
<td className="py-2 px-2">
<td className="py-2 px-2 text-center sticky z-10 bg-white group-hover:bg-gray-50 border-l border-gray-200" style={{ width: 50, right: 0 }}>
<button
onClick={() => removeEmployeeMutation.mutate(entry.employeeId)}
className="text-gray-500 hover:text-danger p-1"