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) // 从上月继承的递延最低工资(本批次已补扣) prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣)
// 风险提示 // 风险提示
riskWarnings Json? riskWarnings Json?
// 自定义模板项的值(非预置项),如 {"heatAllowance": 300}
extraItems Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 entry = await prisma.batchEntry.findUnique({ where: { id: entryId } })
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type, undefined, (entry?.extraItems as Record<string, number>) || null)
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } }) 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++ result.updated++
} catch (e: any) { } catch (e: any) {
result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`) 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, communicationAllowance: entry.communicationAllowance || undefined,
otherDeduction: entry.otherDeduction || undefined, otherDeduction: entry.otherDeduction || undefined,
} }
const calcResult = await calcBatchEntry(orgId, ot.employeeId, batch.month, inputs, batch.type) 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, ...entryData } = calcResult const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({ await prisma.batchEntry.update({
where: { id: entry.id }, 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 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({ await prisma.batchEntry.update({
where: { id: entryId }, 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, prevDeferredHousingEmp: calcResult.prevDeferredHousingEmp || 0,
prevDeferredMinWage: calcResult.prevDeferredMinWage || 0, prevDeferredMinWage: calcResult.prevDeferredMinWage || 0,
riskWarnings, riskWarnings,
extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined,
}, },
}) })
entries.push(entry) entries.push(entry)
@@ -688,7 +689,8 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
} }
}) })
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖) // 编辑批次条目(计算依据项 + 社保公积金手动覆盖 + 自定义模板项
// 预置字段通过固定 schema 校验,自定义字段通过模板 isEditable 动态校验
const updateEntrySchema = z.object({ const updateEntrySchema = z.object({
baseSalary: z.number().optional(), baseSalary: z.number().optional(),
performanceSalary: z.number().optional(), performanceSalary: z.number().optional(),
@@ -700,7 +702,16 @@ const updateEntrySchema = z.object({
socialOrg: z.number().optional(), socialOrg: z.number().optional(),
housingEmp: z.number().optional(), housingEmp: z.number().optional(),
housingOrg: 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) => { router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try { 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: '条目不存在' } }) 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 = { const inputs = {
baseSalary: data.baseSalary ?? entry.baseSalary, baseSalary: data.baseSalary ?? entry.baseSalary,
@@ -724,14 +751,14 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
allowance: data.allowance ?? entry.allowance, allowance: data.allowance ?? entry.allowance,
deduction: data.deduction ?? entry.deduction, deduction: data.deduction ?? entry.deduction,
bonus: data.bonus ?? entry.bonus, 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, performanceSalary: data.performanceSalary ?? entry.performanceSalary ?? undefined,
senioritySalary: (data as any).senioritySalary ?? entry.senioritySalary ?? undefined, senioritySalary: data.senioritySalary ?? entry.senioritySalary ?? undefined,
transportAllowance: entry.transportAllowance || undefined, transportAllowance: data.transportAllowance ?? (entry.transportAllowance || undefined),
mealAllowance: entry.mealAllowance || undefined, mealAllowance: data.mealAllowance ?? (entry.mealAllowance || undefined),
housingAllowance: entry.housingAllowance || undefined, housingAllowance: data.housingAllowance ?? (entry.housingAllowance || undefined),
communicationAllowance: entry.communicationAllowance || undefined, communicationAllowance: data.communicationAllowance ?? (entry.communicationAllowance || undefined),
otherDeduction: entry.otherDeduction || 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 } const options: any = { prevDeferred }
if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial
// 重新计算 // 重新计算(传入 extraItems 供自定义 INPUT 项取值)
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options) 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({ const updated = await prisma.batchEntry.update({
where: { id: entry.id }, 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 calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId) 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({ const entry = await prisma.batchEntry.create({
data: { data: {
batchId, orgId, employeeId, batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0, baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...entryData, riskWarnings, ...entryData, riskWarnings,
extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined,
}, },
}) })
results.push(entry) 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 prevDeferred = await getPrevDeferred(orgId, entry.employeeId, batch.month)
const options: any = { prevDeferred } const options: any = { prevDeferred }
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options) 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, ...entryData } = calcResult const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({ await prisma.batchEntry.update({
where: { id: entry.id }, where: { id: entry.id },
data: { ...entryData }, data: { ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined },
}) })
} catch (e: any) { } catch (e: any) {
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`) recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
+14 -3
View File
@@ -401,9 +401,10 @@ export async function calcBatchEntry(
orgId: string, orgId: string,
employeeId: string, employeeId: string,
month: 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', batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number }; prevDeferred?: { socialEmp?: number; housingEmp?: number; minWage?: number } }, 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({ const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId }, where: { id: employeeId, orgId },
@@ -469,10 +470,10 @@ export async function calcBatchEntry(
_socialCalculated: false, _socialCalculated: false,
} }
// 1. 初始化输入项 // 1. 初始化输入项:先从固定列 inputs 取,再从 extraItems 补充自定义项
for (const item of templateItems) { for (const item of templateItems) {
if (item.type === 'INPUT') { 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 { return {
socialEmp: Math.round(socialEmp * 100) / 100, socialEmp: Math.round(socialEmp * 100) / 100,
socialOrg: Math.round(socialOrg * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100,
@@ -627,6 +637,7 @@ export async function calcBatchEntry(
prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0, prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0,
prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0, prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0,
prevDeferredMinWage, 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'] : ['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) => { const startEdit = (employeeId: string, field: string, currentValue: number) => {
if (isArchived || !editableFields.includes(field)) return 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) const entry = batch.entries.find((e: any) => e.employeeId === employeeId)
if (!entry) { setEditCell(null); return } if (!entry) { setEditCell(null); return }
const data: any = {} 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 => { inputFields.forEach(f => {
data[f] = f === field ? numValue : entry[f] data[f] = f === field ? numValue : entry[f]
}) })
// 社保公积金字段:编辑哪个提交哪个 } else {
const socialFields = ['socialEmp', 'housingEmp', 'socialOrg', 'housingOrg'] // 自定义字段走 extraItems
if (socialFields.includes(field)) { const existingExtra = entry.extraItems || {}
data[field] = numValue data.extraItems = { ...existingExtra, [field]: numValue }
} }
updateEntryMutation.mutate({ employeeId, data }) updateEntryMutation.mutate({ employeeId, data })
setEditCell(null) setEditCell(null)
@@ -793,7 +801,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === field const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === field
const canEdit = !isArchived && editableFields.includes(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) const displayValue = field === 'deduction' && value > 0 ? '-' + fmt(value) : fmt(value)
if (isEditing) { if (isEditing) {
@@ -1226,33 +1234,28 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
{/* 人员表格 */} {/* 人员表格 */}
<Card> <Card>
<div className="overflow-x-auto"> <div className="overflow-x-auto" style={{ maxWidth: '100%' }}>
<table className="w-full text-sm"> <table className="text-sm" style={{ borderCollapse: 'separate', borderSpacing: 0, minWidth: 1200 }}>
<thead> <thead>
<tr className="border-b text-left text-xs text-gray-500"> <tr className="text-left text-xs text-gray-500">
<th className="py-2 px-2"></th> <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"></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>
<th className="py-2 px-2 text-right"></th> {/* 动态生成模板项列(排除 netPay,由固定实发列显示) */}
<th className="py-2 px-2 text-right"></th> {(templateItems || []).filter((t: any) => (!isBonus || t.code === 'bonus') && t.code !== 'netPay').map((t: any) => (
<th className="py-2 px-2 text-right"></th> <th key={t.code} className={`py-2 px-2 text-right whitespace-nowrap ${t.type === 'CALCULATED' ? 'text-gray-500' : ''}`} style={{ minWidth: 80 }}>
<th className="py-2 px-2 text-right"></th> {t.name}
{isBonus && <th className="py-2 px-2 text-right"></th>} </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 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"></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-right">()</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>
<th className="py-2 px-2 text-right">()</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>}
<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> </tr>
</thead> </thead>
<tbody> <tbody>
{batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => ( {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"> <tr key={entry.id} className="group border-b last:border-0">
<td className="py-2 px-2"> <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="flex flex-col gap-0.5">
<div className="text-xs font-medium">{entry.employee.name}</div> <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>} {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> </div>
</td> </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 ct = entry.employee.contracts?.[0]?.contractType
const cfg: Record<string, { label: string; style: string }> = { const cfg: Record<string, { label: string; style: string }> = {
@@ -1285,12 +1288,13 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
) )
})()} })()}
</td> </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 perfVal = entry.performanceSalary || 0
const grade = entry.perfGrade || null const grade = entry.perfGrade || null
const coef = entry.perfCoefficient != null ? entry.perfCoefficient : null const coef = entry.perfCoefficient != null ? entry.perfCoefficient : null
// 颜色:A(1.2)=绿色, C(0.8)=橙色, D(0.6)=红色, B(1.0)/无=默认
let colorClass = '' let colorClass = ''
if (coef != null && coef !== 1.0) { if (coef != null && coef !== 1.0) {
if (coef > 1.0) colorClass = 'text-green-600' if (coef > 1.0) colorClass = 'text-green-600'
@@ -1337,23 +1341,25 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</div> </div>
</td> </td>
) )
})()} }
{renderCell(entry, 'overtimePay')} // 个税:保留点击查看明细
{renderCell(entry, 'allowance')} if (t.code === 'tax') {
{renderCell(entry, 'bonus')} return (
{renderCell(entry, 'deduction')} <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 className="py-2 px-2 text-right font-medium text-gray-600">{fmt(entry.totalPay)}</td> </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> // 其他项:通用渲染
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)} {fmt(entry.netPay)}
{(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1"></span>} {(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1"></span>}
</td> </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 deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0)
const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 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> return <span className="text-gray-300"></span>
})()} })()}
</td> </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 ? ( {entry.riskWarnings && entry.riskWarnings.length > 0 ? (
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}> <span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
<AlertTriangle className="w-4 h-4" /> <AlertTriangle className="w-4 h-4" />
@@ -1380,7 +1386,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
)} )}
</td> </td>
{!isArchived && ( {!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 <button
onClick={() => removeEmployeeMutation.mutate(entry.employeeId)} onClick={() => removeEmployeeMutation.mutate(entry.employeeId)}
className="text-gray-500 hover:text-danger p-1" className="text-gray-500 hover:text-danger p-1"