feat: 发薪批次表格动态化 - 根据薪酬模板动态生成列
- schema: BatchEntry 增加 extraItems Json 字段存储自定义模板项 - calcBatchEntry: 新增 extraItems 参数,合并自定义 INPUT 项取值 - 后端编辑接口: 动态校验自定义字段,预置字段走固定列,自定义走 extraItems - 所有 calcBatchEntry 调用处传入并写回 extraItems - 前端表头/单元格从 templateItems 动态生成,排除 netPay 重复列 - 前端 saveEdit 动态化: 预置字段走固定列,自定义字段走 extraItems - renderCell 支持从 extraItems 取值 - 表格列左右锁定: 员工/合同类型左锁,实发/递延/风险/操作右锁 - 合理列宽 + 横向滚动 + hover 效果
This commit is contained in:
@@ -952,6 +952,8 @@ model BatchEntry {
|
||||
prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣)
|
||||
// 风险提示
|
||||
riskWarnings Json?
|
||||
// 自定义模板项的值(非预置项),如 {"heatAllowance": 300}
|
||||
extraItems Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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 || '导入失败'}`)
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '重算失败'}`)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user