feat: 发薪批次表格动态化 - 根据薪酬模板动态生成列
- schema: BatchEntry 增加 extraItems Json 字段存储自定义模板项 - calcBatchEntry: 新增 extraItems 参数,合并自定义 INPUT 项取值 - 后端编辑接口: 动态校验自定义字段,预置字段走固定列,自定义走 extraItems - 所有 calcBatchEntry 调用处传入并写回 extraItems - 前端表头/单元格从 templateItems 动态生成,排除 netPay 重复列 - 前端 saveEdit 动态化: 预置字段走固定列,自定义字段走 extraItems - renderCell 支持从 extraItems 取值 - 表格列左右锁定: 员工/合同类型左锁,实发/递延/风险/操作右锁 - 合理列宽 + 横向滚动 + hover 效果
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user