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
+2
View File
@@ -952,6 +952,8 @@ model BatchEntry {
prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣)
// 风险提示
riskWarnings Json?
// 自定义模板项的值(非预置项),如 {"heatAllowance": 300}
extraItems Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+4 -3
View File
@@ -890,9 +890,10 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
}
// 重新计算税费
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
const entry = await prisma.batchEntry.findUnique({ where: { id: entryId } })
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type, undefined, (entry?.extraItems as Record<string, number>) || null)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined } })
result.updated++
} catch (e: any) {
result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`)
+3 -3
View File
@@ -790,11 +790,11 @@ router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res:
communicationAllowance: entry.communicationAllowance || undefined,
otherDeduction: entry.otherDeduction || undefined,
}
const calcResult = await calcBatchEntry(orgId, ot.employeeId, batch.month, inputs, batch.type)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
const calcResult = await calcBatchEntry(orgId, ot.employeeId, batch.month, inputs, batch.type, undefined, (entry.extraItems as Record<string, number>) || null)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entry.id },
data: entryData,
data: { ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined },
})
}
+50 -20
View File
@@ -80,12 +80,12 @@ async function recalcEntry(orgId: string, batchId: string, batchMonth: string, b
}
const prevDeferred = await getPrevDeferred(orgId, employeeId, batchMonth)
const calcResult = await calcBatchEntry(orgId, employeeId, batchMonth, inputs, batchType as any, { prevDeferred })
const calcResult = await calcBatchEntry(orgId, employeeId, batchMonth, inputs, batchType as any, { prevDeferred }, (entry.extraItems as Record<string, number>) || null)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entryId },
data: entryData,
data: { ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined },
})
}
@@ -652,6 +652,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
prevDeferredHousingEmp: calcResult.prevDeferredHousingEmp || 0,
prevDeferredMinWage: calcResult.prevDeferredMinWage || 0,
riskWarnings,
extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined,
},
})
entries.push(entry)
@@ -688,7 +689,8 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
}
})
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖 + 自定义模板项
// 预置字段通过固定 schema 校验,自定义字段通过模板 isEditable 动态校验
const updateEntrySchema = z.object({
baseSalary: z.number().optional(),
performanceSalary: z.number().optional(),
@@ -700,7 +702,16 @@ const updateEntrySchema = z.object({
socialOrg: z.number().optional(),
housingEmp: z.number().optional(),
housingOrg: z.number().optional(),
})
positionSalary: z.number().optional(),
senioritySalary: z.number().optional(),
transportAllowance: z.number().optional(),
mealAllowance: z.number().optional(),
housingAllowance: z.number().optional(),
communicationAllowance: z.number().optional(),
otherDeduction: z.number().optional(),
// 自定义字段通过 extraItems 传递
extraItems: z.record(z.string(), z.number()).optional(),
}).passthrough()
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -717,6 +728,22 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
})
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
// 获取模板,确定哪些自定义字段可编辑
const templateItems = await getTemplate(orgId)
const presetCodes = new Set(['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'socialOrg', 'housingEmp', 'housingOrg', 'positionSalary', 'senioritySalary', 'transportAllowance', 'mealAllowance', 'housingAllowance', 'communicationAllowance', 'otherDeduction'])
const customEditableCodes = new Set(templateItems.filter(i => i.isEditable && !presetCodes.has(i.code)).map(i => i.code))
// 合并 extraItems:已有值 + 新传入的可编辑自定义字段
const existingExtraItems = (entry.extraItems as Record<string, number>) || {}
const newExtraItems = { ...existingExtraItems }
if (data.extraItems) {
for (const [code, val] of Object.entries(data.extraItems)) {
if (customEditableCodes.has(code)) {
newExtraItems[code] = val
}
}
}
// 合并输入项(包含细化薪资字段,避免编辑时丢失岗位工资/绩效工资等)
const inputs = {
baseSalary: data.baseSalary ?? entry.baseSalary,
@@ -724,14 +751,14 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
allowance: data.allowance ?? entry.allowance,
deduction: data.deduction ?? entry.deduction,
bonus: data.bonus ?? entry.bonus,
positionSalary: (data as any).positionSalary ?? entry.positionSalary ?? undefined,
positionSalary: data.positionSalary ?? entry.positionSalary ?? undefined,
performanceSalary: data.performanceSalary ?? entry.performanceSalary ?? undefined,
senioritySalary: (data as any).senioritySalary ?? entry.senioritySalary ?? undefined,
transportAllowance: entry.transportAllowance || undefined,
mealAllowance: entry.mealAllowance || undefined,
housingAllowance: entry.housingAllowance || undefined,
communicationAllowance: entry.communicationAllowance || undefined,
otherDeduction: entry.otherDeduction || undefined,
senioritySalary: data.senioritySalary ?? entry.senioritySalary ?? undefined,
transportAllowance: data.transportAllowance ?? (entry.transportAllowance || undefined),
mealAllowance: data.mealAllowance ?? (entry.mealAllowance || undefined),
housingAllowance: data.housingAllowance ?? (entry.housingAllowance || undefined),
communicationAllowance: data.communicationAllowance ?? (entry.communicationAllowance || undefined),
otherDeduction: data.otherDeduction ?? (entry.otherDeduction || undefined),
}
// 构建社保覆盖参数(如果请求中包含社保字段)
@@ -748,13 +775,15 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
const options: any = { prevDeferred }
if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
// 重新计算(传入 extraItems 供自定义 INPUT 项取值)
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options, newExtraItems)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, extraItems: calcExtraItems, ...entryData } = calcResult
// 合并计算结果中的 extraItems(包含自定义计算项的值)
const finalExtraItems = { ...newExtraItems, ...(calcExtraItems || {}) }
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...entryData },
data: { ...inputs, ...entryData, extraItems: Object.keys(finalExtraItems).length > 0 ? finalExtraItems : undefined },
})
// 更新批次汇总
@@ -1242,12 +1271,13 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, extraItems: _ei, ...entryData } = calcResult
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...entryData, riskWarnings,
extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined,
},
})
results.push(entry)
@@ -1384,11 +1414,11 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
const prevDeferred = await getPrevDeferred(orgId, entry.employeeId, batch.month)
const options: any = { prevDeferred }
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options, (entry.extraItems as Record<string, number>) || null)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...entryData },
data: { ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined },
})
} catch (e: any) {
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
+14 -3
View File
@@ -401,9 +401,10 @@ export async function calcBatchEntry(
orgId: string,
employeeId: string,
month: string,
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number },
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number; [key: string]: any },
batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number }; prevDeferred?: { socialEmp?: number; housingEmp?: number; minWage?: number } },
extraItems?: Record<string, number> | null,
) {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
@@ -469,10 +470,10 @@ export async function calcBatchEntry(
_socialCalculated: false,
}
// 1. 初始化输入项
// 1. 初始化输入项:先从固定列 inputs 取,再从 extraItems 补充自定义项
for (const item of templateItems) {
if (item.type === 'INPUT') {
ctx.values[item.code] = (inputs as any)[item.code] || 0
ctx.values[item.code] = (inputs as any)[item.code] ?? (extraItems?.[item.code] ?? 0)
}
}
@@ -601,6 +602,15 @@ export async function calcBatchEntry(
}
}
// 提取非预置项的计算结果到 extraItems
const presetCodes = new Set(DEFAULT_ITEMS.map(i => i.code))
const resultExtraItems: Record<string, number> = {}
for (const item of templateItems) {
if (!presetCodes.has(item.code) && ctx.values[item.code] !== undefined) {
resultExtraItems[item.code] = Math.round((ctx.values[item.code] || 0) * 100) / 100
}
}
return {
socialEmp: Math.round(socialEmp * 100) / 100,
socialOrg: Math.round(socialOrg * 100) / 100,
@@ -627,6 +637,7 @@ export async function calcBatchEntry(
prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0,
prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0,
prevDeferredMinWage,
extraItems: resultExtraItems,
}
}
+53 -47
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']
// 社保公积金字段:编辑哪个提交哪个
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]
})
// 社保公积金字段:编辑哪个提交哪个
const socialFields = ['socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
if (socialFields.includes(field)) {
data[field] = numValue
} 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,12 +1288,13 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)
})()}
</td>
{renderCell(entry, 'baseSalary')}
{(() => {
{/* 动态生成模板项单元格(排除 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
// 颜色: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'
@@ -1337,23 +1341,25 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</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"