fix: portal端token过期后自动跳转登录页,避免显示空数据
- portalAxios 401 响应拦截器:清除 token 并跳转 /portal/login - PortalLayoutWrapper:检查 token 是否存在,不存在则跳转登录页 - 排除 /portal/login 和 /portal/auto-login 页面避免重复跳转 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -240,7 +240,9 @@ model Employee {
|
||||
// 组织架构扩展
|
||||
departmentId String? // 关联 Department 表(兼容旧 department 字符串)
|
||||
supervisorId String? // 直属上级员工ID
|
||||
monthlySalary String // AES-256 加密存储
|
||||
monthlySalary String // AES-256 加密存储(月薪总额 = 基本工资 + 绩效工资)
|
||||
baseSalary String? // 基本工资(AES-256 加密,null=旧数据未拆分)
|
||||
performanceSalary String? // 绩效工资(AES-256 加密,可为"0",null=旧数据未拆分)
|
||||
status EmployeeStatus @default(ACTIVE)
|
||||
gender String?
|
||||
phone String?
|
||||
@@ -348,6 +350,8 @@ model LaborContract {
|
||||
contractYears Int @default(3)
|
||||
probationMonths Int @default(0)
|
||||
probationSalary Int @default(0)
|
||||
baseSalary Int @default(0) // 基本工资(转正后)
|
||||
performanceSalary Int @default(0) // 绩效工资(转正后,可为0)
|
||||
renewalCount Int @default(0)
|
||||
attachmentName String?
|
||||
attachmentUrl String?
|
||||
@@ -713,6 +717,7 @@ model DisciplinaryRecord {
|
||||
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
||||
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
||||
actionDetail String?
|
||||
deductionAmount Float @default(0) // 扣款金额(action=DEDUCTION 时填写,用于薪资批次导入)
|
||||
employeeAck Boolean @default(false) // 员工是否签字确认
|
||||
ackDate DateTime?
|
||||
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
||||
@@ -820,6 +825,7 @@ model Payslip {
|
||||
month String // YYYY-MM
|
||||
// 薪酬构成
|
||||
baseSalary Float @default(0)
|
||||
performanceSalary Float @default(0) // 绩效工资
|
||||
overtimePay Float @default(0)
|
||||
weekdayOvertimePay Float @default(0)
|
||||
weekendOvertimePay Float @default(0)
|
||||
@@ -1194,6 +1200,7 @@ model AttendanceConfirmation {
|
||||
weekendHours Float @default(0)
|
||||
holidayHours Float @default(0)
|
||||
overtimePay Float @default(0)
|
||||
deductionAmount Float @default(0) // 考勤扣款金额(迟到/早退/旷工等,手动填写,用于薪资批次导入)
|
||||
confirmedAt DateTime?
|
||||
confirmedBy String?
|
||||
confirmIp String?
|
||||
|
||||
@@ -67,6 +67,7 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, ne
|
||||
weekendHours: z.number().min(0),
|
||||
holidayHours: z.number().min(0),
|
||||
overtimePay: z.number().min(0),
|
||||
deductionAmount: z.number().min(0).optional(),
|
||||
})),
|
||||
})
|
||||
const { month, items } = schema.parse(req.body)
|
||||
@@ -126,6 +127,22 @@ router.post('/batch-confirm', authMiddleware, async (req: AuthRequest, res: Resp
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/** 更新考勤确认的扣款金额 */
|
||||
router.put('/confirmations/:id/deduction', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { deductionAmount } = req.body
|
||||
const record = await prisma.attendanceConfirmation.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '考勤确认记录不存在' } })
|
||||
const updated = await prisma.attendanceConfirmation.update({
|
||||
where: { id: req.params.id },
|
||||
data: { deductionAmount: Number(deductionAmount) || 0 },
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 班次管理 ==========
|
||||
|
||||
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -366,16 +366,28 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
|
||||
for (const emp of employees) {
|
||||
let baseSalary = 0
|
||||
let performanceSalary = 0
|
||||
let overtimePay = 0
|
||||
let allowance = 0
|
||||
let deduction = 0
|
||||
let bonus = 0
|
||||
|
||||
// 解密员工工资结构
|
||||
let empBaseSalary = 0
|
||||
let empPerformanceSalary = 0
|
||||
if (emp.baseSalary) {
|
||||
try { empBaseSalary = Number(decrypt(emp.baseSalary)) || 0 } catch { empBaseSalary = Number(emp.baseSalary) || 0 }
|
||||
}
|
||||
if (emp.performanceSalary) {
|
||||
try { empPerformanceSalary = Number(decrypt(emp.performanceSalary)) || 0 } catch { empPerformanceSalary = Number(emp.performanceSalary) || 0 }
|
||||
}
|
||||
|
||||
if (mode === 'copy_batch' && sourceEntries) {
|
||||
// 复制指定批次:从源条目复制数据
|
||||
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
||||
if (srcEntry) {
|
||||
baseSalary = srcEntry.baseSalary
|
||||
performanceSalary = srcEntry.performanceSalary || 0
|
||||
overtimePay = srcEntry.overtimePay
|
||||
allowance = srcEntry.allowance
|
||||
deduction = srcEntry.deduction
|
||||
@@ -397,17 +409,40 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
const inProbation = isInProbation(latestContract, batchMonthEnd)
|
||||
if (inProbation && latestContract.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
performanceSalary = 0 // 试用期一般不发绩效
|
||||
} else if (prevPayslip) {
|
||||
// 非试用期:优先用上月工资条的基本工资(保持薪资连续性)
|
||||
baseSalary = prevPayslip.baseSalary
|
||||
performanceSalary = prevPayslip.performanceSalary || 0
|
||||
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
|
||||
// 新数据:有工资结构
|
||||
baseSalary = empBaseSalary
|
||||
performanceSalary = empPerformanceSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
// 旧数据兼容:回退到 monthlySalary
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
overtimePay = overtime?.totalPay || 0
|
||||
allowance = prevPayslip?.allowance || 0
|
||||
deduction = prevPayslip?.deduction || 0
|
||||
} else if (mode === 'custom' || mode === 'blank_employees') {
|
||||
// 自定义/空白员工模式:取员工工资结构(非试用期)
|
||||
const batchMonthEnd = new Date(`${month}-28T23:59:59`)
|
||||
const latestContract = emp.contracts?.[0]
|
||||
const inProbation = isInProbation(latestContract, batchMonthEnd)
|
||||
if (inProbation && latestContract?.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
performanceSalary = 0
|
||||
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
|
||||
baseSalary = empBaseSalary
|
||||
performanceSalary = empPerformanceSalary
|
||||
} else if (emp.monthlySalary && mode === 'custom') {
|
||||
// custom 模式旧数据兼容
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
// blank_employees 模式:金额默认0,不自动带出
|
||||
}
|
||||
// blank_employees 和 blank_all: 所有金额默认 0(不自动带出基本工资,不取试用期工资)
|
||||
// blank_all: 所有金额默认 0
|
||||
|
||||
// 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外)
|
||||
// blank_employees / blank_all 模式下所有金额应为 0,不覆盖试用期工资
|
||||
@@ -416,6 +451,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
const latestContract = emp.contracts?.[0]
|
||||
if (isInProbation(latestContract, batchMonthEnd) && latestContract?.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
performanceSalary = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +478,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
try {
|
||||
// 查询上月递延数据(上月已归档批次中该员工的递延金额)
|
||||
const prevDeferred = await getPrevDeferred(orgId, emp.id, month)
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred })
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, performanceSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred })
|
||||
} catch (calcErr: any) {
|
||||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
||||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
||||
@@ -459,6 +495,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
orgId,
|
||||
employeeId: emp.id,
|
||||
baseSalary,
|
||||
performanceSalary,
|
||||
overtimePay,
|
||||
allowance,
|
||||
deduction,
|
||||
@@ -692,6 +729,299 @@ router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Respo
|
||||
}
|
||||
})
|
||||
|
||||
// 获取绩效工资:按批次月份从 PerformanceRecord 查考核等级,结合 Employee.performanceSalary 计算绩效工资
|
||||
// 绩效系数:A=1.2, B=1.0, C=0.8, D=0.6;无绩效记录则系数=1.0
|
||||
router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||
|
||||
// 获取批次所有条目
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId },
|
||||
select: { id: true, employeeId: true, performanceSalary: true },
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||
}
|
||||
|
||||
// 查询批次月份的绩效记录(按 period = YYYY-MM 匹配)
|
||||
const performanceRecords = await prisma.performanceRecord.findMany({
|
||||
where: { orgId, period: batch.month, periodType: 'MONTHLY' },
|
||||
select: { employeeId: true, grade: true, score: true, result: true },
|
||||
})
|
||||
const perfMap = new Map<string, { grade: string; score: number }>()
|
||||
for (const r of performanceRecords) {
|
||||
// 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证)
|
||||
if (!perfMap.has(r.employeeId)) {
|
||||
perfMap.set(r.employeeId, { grade: r.grade, score: r.score })
|
||||
}
|
||||
}
|
||||
|
||||
// 查询员工绩效工资基数
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { id: { in: entries.map(e => e.employeeId) } },
|
||||
select: { id: true, performanceSalary: true },
|
||||
})
|
||||
const empPerfSalaryMap = new Map<string, number>()
|
||||
for (const emp of employees) {
|
||||
if (emp.performanceSalary) {
|
||||
try { empPerfSalaryMap.set(emp.id, Number(decrypt(emp.performanceSalary)) || 0) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// 绩效系数映射
|
||||
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
||||
|
||||
let filled = 0
|
||||
let totalAmount = 0
|
||||
for (const entry of entries) {
|
||||
const basePerfSalary = empPerfSalaryMap.get(entry.employeeId) || 0
|
||||
if (basePerfSalary <= 0) continue // 绩效工资基数为0则跳过
|
||||
|
||||
const perfRecord = perfMap.get(entry.employeeId)
|
||||
const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0
|
||||
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
|
||||
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { performanceSalary: calculatedPerfSalary },
|
||||
})
|
||||
filled++
|
||||
totalAmount += calculatedPerfSalary
|
||||
}
|
||||
|
||||
// 重算批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
|
||||
totalNetPay: acc.totalNetPay + e.netPay,
|
||||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||||
totalTax: acc.totalTax + e.tax,
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filled,
|
||||
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||
message: filled > 0
|
||||
? `已填充 ${filled} 人绩效工资,合计 ¥${Math.round(totalAmount * 100) / 100}${performanceRecords.length > 0 ? `(${performanceRecords.length} 人有考核记录)` : '(无考核记录,按系数1.0计算)'}`
|
||||
: '无绩效工资数据(员工未设置绩效工资基数)',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取违纪扣款:按批次月份从 DisciplinaryRecord 查 action=DEDUCTION 的记录,汇总扣款金额填充到 entries.deduction
|
||||
router.post('/batches/:batchId/fetch-disciplinary', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||
|
||||
// 获取批次所有条目
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId },
|
||||
select: { id: true, employeeId: true, deduction: true },
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||
}
|
||||
|
||||
// 查询批次月份内的违纪扣款记录(按 violationDate 落在批次月份内)
|
||||
const monthStart = new Date(batch.month + '-01')
|
||||
const monthEnd = new Date(monthStart)
|
||||
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
||||
|
||||
const disciplinaryRecords = await prisma.disciplinaryRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId: { in: entries.map(e => e.employeeId) },
|
||||
action: 'DEDUCTION',
|
||||
deductionAmount: { gt: 0 },
|
||||
violationDate: { gte: monthStart, lt: monthEnd },
|
||||
},
|
||||
select: { employeeId: true, deductionAmount: true },
|
||||
})
|
||||
|
||||
// 按员工汇总扣款金额
|
||||
const deductionMap = new Map<string, number>()
|
||||
for (const r of disciplinaryRecords) {
|
||||
deductionMap.set(r.employeeId, (deductionMap.get(r.employeeId) || 0) + r.deductionAmount)
|
||||
}
|
||||
|
||||
let filled = 0
|
||||
let totalAmount = 0
|
||||
for (const entry of entries) {
|
||||
const deduction = deductionMap.get(entry.employeeId)
|
||||
if (deduction && deduction > 0) {
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { deduction: Math.round(deduction * 100) / 100 },
|
||||
})
|
||||
filled++
|
||||
totalAmount += deduction
|
||||
}
|
||||
}
|
||||
|
||||
// 重算批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
|
||||
totalNetPay: acc.totalNetPay + e.netPay,
|
||||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||||
totalTax: acc.totalTax + e.tax,
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filled,
|
||||
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||
message: filled > 0
|
||||
? `已填充 ${filled} 人违纪扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
|
||||
: `${batch.month} 无违纪扣款数据`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取考勤扣款:按批次月份从 AttendanceConfirmation 查 deductionAmount > 0 的记录,填充到 entries.deduction
|
||||
router.post('/batches/:batchId/fetch-attendance-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||
|
||||
// 获取批次所有条目
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId },
|
||||
select: { id: true, employeeId: true, deduction: true },
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||
}
|
||||
|
||||
// 查询批次月份的考勤确认记录
|
||||
const confirmations = await prisma.attendanceConfirmation.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId: { in: entries.map(e => e.employeeId) },
|
||||
month: batch.month,
|
||||
deductionAmount: { gt: 0 },
|
||||
},
|
||||
select: { employeeId: true, deductionAmount: true },
|
||||
})
|
||||
|
||||
const deductionMap = new Map<string, number>()
|
||||
for (const c of confirmations) {
|
||||
deductionMap.set(c.employeeId, c.deductionAmount)
|
||||
}
|
||||
|
||||
let filled = 0
|
||||
let totalAmount = 0
|
||||
for (const entry of entries) {
|
||||
const deduction = deductionMap.get(entry.employeeId)
|
||||
if (deduction && deduction > 0) {
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { deduction: Math.round(deduction * 100) / 100 },
|
||||
})
|
||||
filled++
|
||||
totalAmount += deduction
|
||||
}
|
||||
}
|
||||
|
||||
// 重算批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
|
||||
totalNetPay: acc.totalNetPay + e.netPay,
|
||||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||||
totalTax: acc.totalTax + e.tax,
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filled,
|
||||
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||
message: filled > 0
|
||||
? `已填充 ${filled} 人考勤扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
|
||||
: `${batch.month} 无考勤扣款数据(需先在考勤确认中填写扣款金额)`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取条目个税计算明细
|
||||
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -220,6 +220,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
idCardMasked,
|
||||
idCardNumber: safeDecryptStr(e.idCardNumber),
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null,
|
||||
performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null,
|
||||
socialInsBase: e.socialInsBase,
|
||||
housingFundBase: e.housingFundBase,
|
||||
socialInsCalc: (() => {
|
||||
@@ -373,7 +375,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
if (found) monthlyProcessRecords.push(recordData)
|
||||
}
|
||||
|
||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
|
||||
@@ -383,6 +385,8 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
...rest,
|
||||
status: dynamicStatus,
|
||||
monthlySalary: safeDecrypt(monthlySalary),
|
||||
baseSalary: baseSalary ? safeDecrypt(baseSalary) : null,
|
||||
performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null,
|
||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||
idCardNumber: safeDecryptStr(idCardNumber),
|
||||
monthlyProcessRecords,
|
||||
@@ -961,7 +965,7 @@ router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest,
|
||||
|
||||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
@@ -972,6 +976,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
||||
severity: severity || 'WARNING',
|
||||
action: action || 'ORAL_WARNING',
|
||||
actionDetail,
|
||||
deductionAmount: Number(deductionAmount) || 0,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
@@ -995,7 +1000,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
||||
|
||||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
@@ -1009,6 +1014,7 @@ router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: Au
|
||||
severity,
|
||||
action,
|
||||
actionDetail,
|
||||
deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
@@ -1412,7 +1418,7 @@ function prevMonth(month: string): string {
|
||||
// 调薪
|
||||
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { newSalary, effectiveMonth, reason } = req.body
|
||||
const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
@@ -1446,10 +1452,13 @@ router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
// 同步 Employee 便捷字段(含工资结构)
|
||||
const updateData: any = { monthlySalary: encrypt(String(newSalary)) }
|
||||
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
|
||||
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
|
||||
await prisma.employee.update({
|
||||
where: { id: req.params.id },
|
||||
data: { monthlySalary: encrypt(String(newSalary)) },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
|
||||
|
||||
@@ -48,6 +48,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
||||
weekendHours: number
|
||||
holidayHours: number
|
||||
overtimePay: number
|
||||
deductionAmount?: number
|
||||
}>) {
|
||||
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
|
||||
|
||||
@@ -66,6 +67,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
||||
weekendHours: item.weekendHours,
|
||||
holidayHours: item.holidayHours,
|
||||
overtimePay: item.overtimePay,
|
||||
deductionAmount: item.deductionAmount || 0,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
@@ -80,6 +82,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
||||
weekendHours: item.weekendHours,
|
||||
holidayHours: item.holidayHours,
|
||||
overtimePay: item.overtimePay,
|
||||
deductionAmount: item.deductionAmount || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,6 +14,15 @@ function dateToMonth(date: Date): string {
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
/** 安全解密数字(工资字段) */
|
||||
function safeDecryptNum(encrypted: string): number {
|
||||
try {
|
||||
return Number(decrypt(encrypted)) || 0
|
||||
} catch {
|
||||
return Number(encrypted) || 0
|
||||
}
|
||||
}
|
||||
|
||||
async function clampSocialInsBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
|
||||
// 优先按账户查年度标准
|
||||
if (accountId) {
|
||||
@@ -271,6 +280,9 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
||||
} catch {
|
||||
decryptedSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
// 解密工资结构
|
||||
const decryptedBaseSalary = emp.baseSalary ? safeDecryptNum(emp.baseSalary) : null
|
||||
const decryptedPerformanceSalary = emp.performanceSalary ? safeDecryptNum(emp.performanceSalary) : null
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
@@ -279,6 +291,8 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
||||
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
||||
status: emp.status,
|
||||
monthlySalary: decryptedSalary,
|
||||
baseSalary: decryptedBaseSalary,
|
||||
performanceSalary: decryptedPerformanceSalary,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
@@ -315,6 +329,8 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
return {
|
||||
...employee,
|
||||
monthlySalary: decryptedSalary,
|
||||
baseSalary: employee.baseSalary ? safeDecryptNum(employee.baseSalary) : null,
|
||||
performanceSalary: employee.performanceSalary ? safeDecryptNum(employee.performanceSalary) : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +384,9 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
const hireDate = new Date(data.hireDate)
|
||||
const hireMonth = dateToMonth(hireDate)
|
||||
const salaryNum = Number(data.monthlySalary) || 0
|
||||
// 工资结构:基本工资 + 绩效工资 = 月薪总额
|
||||
const baseSalaryNum = Number(data.baseSalary) || 0
|
||||
const performanceSalaryNum = Number(data.performanceSalary) || 0
|
||||
const city = data.city || '北京'
|
||||
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
|
||||
const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
|
||||
@@ -389,6 +408,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
department: data.department,
|
||||
hireDate,
|
||||
monthlySalary: encrypt(data.monthlySalary),
|
||||
baseSalary: encrypt(String(baseSalaryNum)),
|
||||
performanceSalary: encrypt(String(performanceSalaryNum)),
|
||||
gender: data.gender,
|
||||
femaleWorkerType: data.femaleWorkerType,
|
||||
phone: data.phone,
|
||||
@@ -488,6 +509,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
baseSalary: baseSalaryNum,
|
||||
performanceSalary: performanceSalaryNum,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
@@ -526,6 +549,10 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
|
||||
const newHireMonth = dateToMonth(newHireDate)
|
||||
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
|
||||
// 重新入职时支持更新工资结构
|
||||
const baseSalaryNum = data.baseSalary != null ? Number(data.baseSalary) : (employee.baseSalary ? Number(decrypt(employee.baseSalary)) || 0 : 0)
|
||||
const performanceSalaryNum = data.performanceSalary != null ? Number(data.performanceSalary) : (employee.performanceSalary ? Number(decrypt(employee.performanceSalary)) || 0 : 0)
|
||||
const newMonthlySalary = data.baseSalary != null ? baseSalaryNum + performanceSalaryNum : salaryNum
|
||||
const city = data.city || employee.city || '北京'
|
||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
@@ -575,6 +602,12 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
housingFundStartMonth,
|
||||
housingFundEndMonth: null,
|
||||
city: data.city || employee.city || '北京',
|
||||
// 更新工资结构
|
||||
...(data.baseSalary != null ? {
|
||||
monthlySalary: encrypt(String(newMonthlySalary)),
|
||||
baseSalary: encrypt(String(baseSalaryNum)),
|
||||
performanceSalary: encrypt(String(performanceSalaryNum)),
|
||||
} : {}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -657,6 +690,8 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
baseSalary: baseSalaryNum,
|
||||
performanceSalary: performanceSalaryNum,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
@@ -702,6 +737,13 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||
const newSalary = Number(data.monthlySalary) || 0
|
||||
updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
// 同步更新工资结构
|
||||
if (data.baseSalary !== undefined) {
|
||||
updateData.baseSalary = encrypt(String(Number(data.baseSalary) || 0))
|
||||
}
|
||||
if (data.performanceSalary !== undefined) {
|
||||
updateData.performanceSalary = encrypt(String(Number(data.performanceSalary) || 0))
|
||||
}
|
||||
// 记录薪资变更
|
||||
if (oldSalary !== newSalary) {
|
||||
const now = new Date()
|
||||
@@ -912,6 +954,8 @@ export async function batchRenew(orgId: string, userId: string, contractIds: str
|
||||
contractYears: years,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
baseSalary: contract.baseSalary || 0,
|
||||
performanceSalary: contract.performanceSalary || 0,
|
||||
renewalCount: contract.renewalCount + 1,
|
||||
createdBy: userId,
|
||||
},
|
||||
@@ -963,6 +1007,8 @@ export async function addContract(orgId: string, userId: string, data: any) {
|
||||
contractYears: data.contractYears || 3,
|
||||
probationMonths: data.probationMonths || 0,
|
||||
probationSalary: data.probationSalary || 0,
|
||||
baseSalary: data.baseSalary || 0,
|
||||
performanceSalary: data.performanceSalary || 0,
|
||||
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
||||
attachmentUrl: data.attachmentUrl || null,
|
||||
electronicContractNo: data.electronicContractNo || null,
|
||||
|
||||
@@ -638,6 +638,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
update: {
|
||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||
allowance: Math.round(summary.allowance * 100) / 100,
|
||||
deduction: Math.round(summary.deduction * 100) / 100,
|
||||
@@ -659,6 +660,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
employeeId,
|
||||
month,
|
||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||
allowance: Math.round(summary.allowance * 100) / 100,
|
||||
deduction: Math.round(summary.deduction * 100) / 100,
|
||||
|
||||
@@ -18,7 +18,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
name: '固定期限劳动合同',
|
||||
category: 'CONTRACT',
|
||||
description: '标准固定期限劳动合同模板,适用于大多数正式员工',
|
||||
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'],
|
||||
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'baseSalary', 'performanceSalary', 'probationSalary', 'socialInsBase'],
|
||||
content: `劳动合同书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
@@ -36,7 +36,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
|
||||
|
||||
第四条 劳动报酬
|
||||
乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。
|
||||
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。试用期月工资为人民币{{probationSalary}}元。甲方于每月15日前支付上月工资。
|
||||
|
||||
第五条 社会保险和福利
|
||||
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
|
||||
@@ -64,7 +64,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
name: '无固定期限劳动合同',
|
||||
category: 'CONTRACT',
|
||||
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
|
||||
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'],
|
||||
variables: ['companyName', 'employeeName', 'startDate', 'position', 'baseSalary', 'performanceSalary'],
|
||||
content: `无固定期限劳动合同书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
@@ -79,7 +79,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
乙方担任{{position}}岗位。
|
||||
|
||||
第三条 劳动报酬
|
||||
乙方月工资为人民币{{monthlySalary}}元。
|
||||
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。
|
||||
|
||||
(其余条款参照固定期限劳动合同模板)
|
||||
|
||||
@@ -241,7 +241,7 @@ ____年__月__日
|
||||
name: '试用期转正通知书',
|
||||
category: 'NOTICE',
|
||||
description: '试用期考核合格转正通知',
|
||||
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'],
|
||||
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'baseSalary', 'performanceSalary', 'companyName'],
|
||||
content: `试用期转正通知书
|
||||
|
||||
{{employeeName}}:
|
||||
@@ -249,7 +249,7 @@ ____年__月__日
|
||||
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
|
||||
|
||||
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
|
||||
二、转正后月工资为人民币{{monthlySalary}}元。
|
||||
二、转正后基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月。
|
||||
三、试用期至{{probationEndDate}}结束。
|
||||
|
||||
请继续遵守公司各项规章制度,努力工作。
|
||||
|
||||
@@ -66,15 +66,19 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
||||
return { employeeId }
|
||||
}
|
||||
case 'CONFIRM': {
|
||||
const { employeeId, regularSalary } = formData
|
||||
const { employeeId, regularSalary, baseSalary, performanceSalary } = formData
|
||||
if (employeeId) {
|
||||
if (regularSalary) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||||
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
|
||||
const newSalary = Number(regularSalary) || 0
|
||||
// 同步更新工资结构
|
||||
const updateData: any = { monthlySalary: encrypt(String(regularSalary)) }
|
||||
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
|
||||
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { monthlySalary: encrypt(String(regularSalary)) },
|
||||
data: updateData,
|
||||
})
|
||||
// 记录薪资变更(试用期薪资 → 转正薪资)
|
||||
if (oldSalary !== newSalary) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const ForgotPassword = lazyRetry(() => import('./pages/auth/ForgotPassword'))
|
||||
const Dashboard = lazyRetry(() => import('./pages/Dashboard'))
|
||||
const Money = lazyRetry(() => import('./pages/Money'))
|
||||
const SocialInsurance = lazyRetry(() => import('./pages/SocialInsurance'))
|
||||
const Overtime = lazyRetry(() => import('./pages/Overtime'))
|
||||
const Roster = lazyRetry(() => import('./pages/Roster'))
|
||||
const OrgChart = lazyRetry(() => import('./pages/OrgChart'))
|
||||
const SupportDashboard = lazyRetry(() => import('./pages/support/SupportDashboard'))
|
||||
@@ -141,6 +142,12 @@ function PlatformLayout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) {
|
||||
// 检查 portal token 是否存在,不存在则跳转登录页
|
||||
const portalToken = localStorage.getItem('portalToken')
|
||||
if (!portalToken) {
|
||||
window.location.href = '/portal/login'
|
||||
return null
|
||||
}
|
||||
if (!showNav) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface pt-safe pb-safe">
|
||||
@@ -192,6 +199,7 @@ export default function App() {
|
||||
<Route path="/support" element={<ProtectedRoute><AdminLayout><SupportDashboard /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/overtime" element={<ProtectedRoute><AdminLayout><Overtime /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ChevronDown, ChevronRight,
|
||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
||||
DollarSign,
|
||||
DollarSign, Clock,
|
||||
} from 'lucide-react'
|
||||
import { settingsApi } from '../../lib/api-services'
|
||||
|
||||
@@ -64,6 +64,7 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||
{ path: '/overtime', label: '加班费计算', icon: Clock },
|
||||
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
|
||||
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
|
||||
],
|
||||
|
||||
@@ -294,6 +294,12 @@ export const attendanceApi = {
|
||||
/** 批量确认 */
|
||||
batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) =>
|
||||
post('/attendance/batch-confirm', data).then(unwrap<any>()),
|
||||
/** 批量创建/更新考勤确认记录 */
|
||||
batchCreate: (data: { month: string; items: any[] }) =>
|
||||
post('/attendance/batch', data).then(unwrap<any>()),
|
||||
/** 更新考勤确认扣款金额 */
|
||||
updateDeduction: (id: string, deductionAmount: number) =>
|
||||
put(`/attendance/confirmations/${id}/deduction`, { deductionAmount }).then(unwrap<any>()),
|
||||
/** 单条确认 */
|
||||
confirm: (data: { employeeId: string; month: string }) =>
|
||||
post('/attendance/confirm', data).then(unwrap<any>()),
|
||||
@@ -480,6 +486,15 @@ export const payrollApi = {
|
||||
/** 获取提成奖金到批次 */
|
||||
fetchBonusToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap<any>()),
|
||||
/** 获取绩效工资到批次(按考核系数计算) */
|
||||
fetchPerformanceToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-performance`).then(unwrap<any>()),
|
||||
/** 获取违纪扣款到批次 */
|
||||
fetchDisciplinaryToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-disciplinary`).then(unwrap<any>()),
|
||||
/** 获取考勤扣款到批次 */
|
||||
fetchAttendanceDeductionToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-attendance-deduction`).then(unwrap<any>()),
|
||||
/** 加班费配置 */
|
||||
overtimeConfig: () =>
|
||||
get('/payroll/overtime/config').then(unwrap<any>()),
|
||||
@@ -1096,9 +1111,20 @@ portalAxios.interceptors.request.use((config: any) => {
|
||||
return config
|
||||
})
|
||||
// 与管理端 api 实例一致:response interceptor 返回 response.data(后端 JSON body)
|
||||
// 401 时清除 token 并跳转登录页(避免 token 过期后显示空数据)
|
||||
portalAxios.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => Promise.reject(error),
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem('portalToken')
|
||||
localStorage.removeItem('portalEmployee')
|
||||
// 避免在登录页重复跳转
|
||||
if (!window.location.pathname.includes('/portal/login') && !window.location.pathname.includes('/portal/auto-login')) {
|
||||
window.location.href = '/portal/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any
|
||||
const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any
|
||||
|
||||
@@ -110,7 +110,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [editItem, setEditItem] = useState<any>(null)
|
||||
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0 })
|
||||
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0, deductionAmount: 0 })
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const pageSize = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -193,7 +193,19 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
return await attendanceApi.manualCorrect(data)
|
||||
// 通过批量创建接口更新考勤确认记录(upsert 语义)
|
||||
return await attendanceApi.batchCreate({
|
||||
month: data.month,
|
||||
items: [{
|
||||
employeeId: data.employeeId,
|
||||
workDays: data.workDays,
|
||||
weekdayHours: 0,
|
||||
weekendHours: 0,
|
||||
holidayHours: 0,
|
||||
overtimePay: data.overtimePay,
|
||||
deductionAmount: data.deductionAmount || 0,
|
||||
}],
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('考勤记录已修改')
|
||||
@@ -421,6 +433,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
<span>周末加班 {item.weekendHours}h</span>
|
||||
<span>法定加班 {item.holidayHours}h</span>
|
||||
<span className="text-gray-700">加班费 ¥{item.overtimePay?.toFixed(2)}</span>
|
||||
{item.deductionAmount > 0 && <span className="text-danger">扣款 ¥{item.deductionAmount?.toFixed(2)}</span>}
|
||||
</div>
|
||||
{item.disputeNote && (
|
||||
<div className="text-xs text-red-600 mt-1">异议说明:{item.disputeNote}</div>
|
||||
@@ -449,6 +462,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
leaveDays: item.leaveDays || 0,
|
||||
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
|
||||
overtimePay: item.overtimePay || 0,
|
||||
deductionAmount: item.deductionAmount || 0,
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -510,6 +524,11 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimePay}
|
||||
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-gray-500">考勤扣款(元)</label>
|
||||
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.deductionAmount}
|
||||
onChange={(e) => setEditForm({ ...editForm, deductionAmount: Number(e.target.value) })} placeholder="迟到/早退/旷工等扣款金额" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}>取消</Button>
|
||||
|
||||
@@ -240,6 +240,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
position: '',
|
||||
hireDate: '',
|
||||
monthlySalary: '',
|
||||
baseSalary: '',
|
||||
performanceSalary: '0',
|
||||
gender: '男' as '男' | '女',
|
||||
phone: '',
|
||||
isPregnant: false,
|
||||
@@ -261,6 +263,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
position: form.position || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary,
|
||||
baseSalary: form.baseSalary,
|
||||
performanceSalary: form.performanceSalary || '0',
|
||||
gender: form.gender,
|
||||
phone: form.phone || undefined,
|
||||
isPregnant: form.isPregnant,
|
||||
@@ -276,6 +280,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
contractYears: form.contractYears,
|
||||
probationMonths: form.probationMonths,
|
||||
probationSalary: form.probationSalary,
|
||||
baseSalary: parseFloat(form.baseSalary) || 0,
|
||||
performanceSalary: parseFloat(form.performanceSalary) || 0,
|
||||
}
|
||||
}
|
||||
onSubmit(data)
|
||||
@@ -317,9 +323,27 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>月工资 *</Label>
|
||||
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>基本工资 *</Label>
|
||||
<Input type="number" value={form.baseSalary} onChange={(e) => {
|
||||
const base = parseFloat(e.target.value) || 0
|
||||
const perf = parseFloat(form.performanceSalary) || 0
|
||||
setForm({ ...form, baseSalary: e.target.value, monthlySalary: base + perf > 0 ? String(base + perf) : '' })
|
||||
}} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效工资</Label>
|
||||
<Input type="number" value={form.performanceSalary} onChange={(e) => {
|
||||
const perf = parseFloat(e.target.value) || 0
|
||||
const base = parseFloat(form.baseSalary) || 0
|
||||
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf > 0 ? String(base + perf) : '' })
|
||||
}} placeholder="可为0" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>月薪合计</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">¥{(parseFloat(form.baseSalary) || 0) + (parseFloat(form.performanceSalary) || 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react'
|
||||
import { Layers, Wallet, LayoutTemplate, Receipt, Loader2 } from 'lucide-react'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import { lazyRetry } from '../lib/lazyRetry'
|
||||
|
||||
const BatchManager = lazyRetry(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
|
||||
const TemplateManager = lazyRetry(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager })))
|
||||
const OvertimeCalculator = lazyRetry(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator })))
|
||||
const PayslipManager = lazyRetry(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager })))
|
||||
|
||||
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
|
||||
type Tab = 'batch' | 'template' | 'payslip'
|
||||
|
||||
export default function Money() {
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -20,18 +19,17 @@ export default function Money() {
|
||||
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
|
||||
{ key: 'template', label: '薪酬模版', icon: <LayoutTemplate className="w-4 h-4" /> },
|
||||
{ key: 'overtime', label: '加班费计算', icon: <Clock className="w-4 h-4" /> },
|
||||
{ key: 'payslip', label: '工资条管理', icon: <Receipt className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>薪酬管理,支持工资条发放、批量算薪、加班费计算和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理。</PageGuide>
|
||||
<PageGuide>薪酬管理,支持工资条发放、批量算薪和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理,加班费在「加班费计算」模块单独计算后导入。</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">薪税管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">统一管理发薪批次、工资条及加班薪酬</p>
|
||||
<p className="mt-1 text-sm text-gray-500">统一管理发薪批次、工资条及薪酬模板</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +51,6 @@ export default function Money() {
|
||||
<Suspense fallback={<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>}>
|
||||
{tab === 'batch' && <BatchManager />}
|
||||
{tab === 'template' && <TemplateManager />}
|
||||
{tab === 'overtime' && <OvertimeCalculator />}
|
||||
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { lazyRetry } from '../lib/lazyRetry'
|
||||
|
||||
const OvertimeCalculator = lazyRetry(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator })))
|
||||
|
||||
/**
|
||||
* 加班费计算独立页面
|
||||
* 从薪税管理子标签拆出,作为一级菜单项
|
||||
*/
|
||||
export default function Overtime() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>}>
|
||||
<OvertimeCalculator />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { usePageSize } from '../../hooks/usePageSize'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck, DollarSign } from 'lucide-react'
|
||||
import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck, DollarSign, CalendarCheck } from 'lucide-react'
|
||||
import { Stepper, type Step } from '../../components/ui/Stepper'
|
||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||
import PageGuide from '../../components/ui/PageGuide'
|
||||
@@ -597,6 +597,54 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
},
|
||||
})
|
||||
|
||||
const fetchPerformanceMutation = useMutation({
|
||||
mutationFn: () => payrollApi.fetchPerformanceToBatch(batchId),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
if (res.data?.filled > 0) {
|
||||
toast.success(res.data.message)
|
||||
} else {
|
||||
toast.info(res.data?.message || '无绩效工资数据')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('获取绩效工资失败')
|
||||
},
|
||||
})
|
||||
|
||||
const fetchDisciplinaryMutation = useMutation({
|
||||
mutationFn: () => payrollApi.fetchDisciplinaryToBatch(batchId),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
if (res.data?.filled > 0) {
|
||||
toast.success(res.data.message)
|
||||
} else {
|
||||
toast.info(res.data?.message || '无违纪扣款数据')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('获取违纪扣款失败')
|
||||
},
|
||||
})
|
||||
|
||||
const fetchAttendanceDeductionMutation = useMutation({
|
||||
mutationFn: () => payrollApi.fetchAttendanceDeductionToBatch(batchId),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
if (res.data?.filled > 0) {
|
||||
toast.success(res.data.message)
|
||||
} else {
|
||||
toast.info(res.data?.message || '无考勤扣款数据')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('获取考勤扣款失败')
|
||||
},
|
||||
})
|
||||
|
||||
const importPayrollMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
@@ -825,6 +873,36 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<DollarSign className="w-4 h-4 mr-1" />
|
||||
{fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fetchPerformanceMutation.mutate()}
|
||||
disabled={fetchPerformanceMutation.isPending || isArchived}
|
||||
title="按考核等级系数计算绩效工资(A=1.2/B=1.0/C=0.8/D=0.6),无考核记录按1.0"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
{fetchPerformanceMutation.isPending ? '获取中...' : '获取绩效工资'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fetchDisciplinaryMutation.mutate()}
|
||||
disabled={fetchDisciplinaryMutation.isPending || isArchived}
|
||||
title="按批次月份从违纪记录中汇总扣款金额(action=DEDUCTION)"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mr-1" />
|
||||
{fetchDisciplinaryMutation.isPending ? '获取中...' : '获取违纪扣款'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fetchAttendanceDeductionMutation.mutate()}
|
||||
disabled={fetchAttendanceDeductionMutation.isPending || isArchived}
|
||||
title="按批次月份从考勤确认中获取扣款金额(需先在考勤确认中填写)"
|
||||
>
|
||||
<CalendarCheck className="w-4 h-4 mr-1" />
|
||||
{fetchAttendanceDeductionMutation.isPending ? '获取中...' : '获取考勤扣款'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -89,6 +89,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
phone: profile.phone || '',
|
||||
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
||||
monthlySalary: profile.monthlySalary || '',
|
||||
baseSalary: profile.baseSalary != null ? profile.baseSalary : '',
|
||||
performanceSalary: profile.performanceSalary != null ? profile.performanceSalary : '0',
|
||||
emergencyContact: profile.emergencyContact || '',
|
||||
emergencyPhone: profile.emergencyPhone || '',
|
||||
address: profile.address || '',
|
||||
@@ -171,6 +173,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
phone: form.phone || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: String(form.monthlySalary),
|
||||
baseSalary: String(form.baseSalary),
|
||||
performanceSalary: String(form.performanceSalary || '0'),
|
||||
emergencyContact: form.emergencyContact || undefined,
|
||||
emergencyPhone: form.emergencyPhone || undefined,
|
||||
address: form.address || undefined,
|
||||
@@ -235,7 +239,9 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
: []),
|
||||
]
|
||||
const salaryFields = [
|
||||
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
|
||||
{ label: '月工资', value: profile.baseSalary != null || profile.performanceSalary != null
|
||||
? `¥${fmt(profile.monthlySalary)}(基本 ¥${fmt(profile.baseSalary || 0)} + 绩效 ¥${fmt(profile.performanceSalary || 0)})`
|
||||
: `¥${fmt(profile.monthlySalary)}` },
|
||||
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
|
||||
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
|
||||
{ label: '住址', value: profile.address || '未填写' },
|
||||
@@ -378,7 +384,22 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
||||
<div><Label>学历</Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value="">未选择</option><option value="博士">博士</option><option value="硕士">硕士</option><option value="本科">本科</option><option value="大专">大专</option><option value="高中">高中</option><option value="其他">其他</option></Select></div>
|
||||
<div><Label>职务/岗位</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
|
||||
<div><Label>入职日期</Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
|
||||
<div><Label>月工资</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
|
||||
<div>
|
||||
<Label>基本工资</Label>
|
||||
<Input type="number" value={form.baseSalary} onChange={(e) => {
|
||||
const base = parseFloat(e.target.value) || 0
|
||||
const perf = parseFloat(form.performanceSalary) || 0
|
||||
setForm({ ...form, baseSalary: e.target.value, monthlySalary: base + perf })
|
||||
}} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效工资</Label>
|
||||
<Input type="number" value={form.performanceSalary} onChange={(e) => {
|
||||
const perf = parseFloat(e.target.value) || 0
|
||||
const base = parseFloat(form.baseSalary) || 0
|
||||
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf })
|
||||
}} placeholder="可为0" />
|
||||
</div>
|
||||
<div><Label>紧急联系人</Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
|
||||
<div><Label>紧急联系电话</Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
|
||||
<div className="md:col-span-2"><Label>住址</Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
|
||||
|
||||
@@ -10,11 +10,11 @@ import Button from "../../components/ui/Button"
|
||||
import { Input, Label, Select } from "../../components/ui/Input"
|
||||
import { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download, PenTool } from "lucide-react"
|
||||
|
||||
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
|
||||
export default function ContractInfo({ employeeId, contracts, hireDate, employee }: { employeeId: string; contracts: any[]; hireDate: string; employee?: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
|
||||
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, baseSalary: 0, performanceSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
|
||||
const contractFileRef = useRef<HTMLInputElement>(null)
|
||||
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
@@ -200,6 +200,8 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
endDate: '',
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
baseSalary: employee?.baseSalary != null ? employee.baseSalary : 0,
|
||||
performanceSalary: employee?.performanceSalary != null ? employee.performanceSalary : 0,
|
||||
signMethod: 'PAPER',
|
||||
attachmentUrl: '',
|
||||
attachments: [],
|
||||
@@ -250,6 +252,8 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
<div><Label>试用期工资</Label><Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} /></div>
|
||||
</>
|
||||
)}
|
||||
<div><Label>基本工资</Label><Input type="number" value={form.baseSalary} onChange={(e) => setForm({ ...form, baseSalary: parseFloat(e.target.value) || 0 })} placeholder="元/月" /></div>
|
||||
<div><Label>绩效工资</Label><Input type="number" value={form.performanceSalary} onChange={(e) => setForm({ ...form, performanceSalary: parseFloat(e.target.value) || 0 })} placeholder="可为0" /></div>
|
||||
<div className="md:col-span-2 border-t pt-3">
|
||||
<Label>签订方式</Label>
|
||||
<Select value={form.signMethod} onChange={(e) => setForm({ ...form, signMethod: e.target.value as 'PAPER' | 'ELECTRONIC' })}>
|
||||
@@ -324,6 +328,8 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">合同开始</span><span className="font-medium text-right truncate ml-2">{c.startDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">合同结束</span><span className="font-medium text-right truncate ml-2">{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">合同期限</span><span className="font-medium text-right truncate ml-2">{c.contractYears}年</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">基本工资</span><span className="font-medium text-right truncate ml-2">¥{c.baseSalary || 0}/月</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">绩效工资</span><span className="font-medium text-right truncate ml-2">¥{c.performanceSalary || 0}/月</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">试用期</span><span className="font-medium text-right truncate ml-2">{c.probationMonths}个月(¥{c.probationSalary})</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">签订方式</span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">续签次数</span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
export default function DisciplinaryInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ violationDate: '', violationType: 'LATE', description: '', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '', employeeAck: false, ackDate: '', ackMethod: 'SIGN', witness: '' })
|
||||
const [form, setForm] = useState({ violationDate: '', violationType: 'LATE', description: '', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '', deductionAmount: 0, employeeAck: false, ackDate: '', ackMethod: 'SIGN', witness: '' })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => rosterApi.createDisciplinary(employeeId, data),
|
||||
@@ -56,7 +56,10 @@ export default function DisciplinaryInfo({ employeeId, records }: { employeeId:
|
||||
{Object.entries(actionMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>处理详情</Label><Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="扣款金额/降职说明等" /></div>
|
||||
<div><Label>处理详情</Label><Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="降职说明等" /></div>
|
||||
{form.action === 'DEDUCTION' && (
|
||||
<div><Label>扣款金额(元)</Label><Input type="number" value={form.deductionAmount} onChange={(e) => setForm({ ...form, deductionAmount: Number(e.target.value) || 0 })} placeholder="扣款金额,用于薪资批次导入" /></div>
|
||||
)}
|
||||
<div><Label>见证人</Label><Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} /></div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input type="checkbox" id="empAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
|
||||
@@ -90,6 +93,7 @@ export default function DisciplinaryInfo({ employeeId, records }: { employeeId:
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-400">处理方式</span>
|
||||
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-600">{actionMap[r.action] || r.action}</span>
|
||||
{r.deductionAmount > 0 && <span className="text-danger font-medium">扣款 ¥{r.deductionAmount.toFixed(2)}</span>}
|
||||
{r.actionDetail && <span className="text-gray-500">{r.actionDetail}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs pt-1 border-t">
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function DisciplinaryRecords() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{ACTION_LABELS[r.action] || r.action}</td>
|
||||
<td className="py-2 pr-4 max-w-xs truncate text-gray-500 text-xs" title={r.actionDetail}>{r.actionDetail || '-'}</td>
|
||||
<td className="py-2 pr-4 max-w-xs truncate text-gray-500 text-xs" title={r.actionDetail}>{r.actionDetail || '-'}{r.deductionAmount > 0 && <span className="text-danger"> 扣款¥{r.deductionAmount.toFixed(2)}</span>}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{r.employeeAck ? (
|
||||
<span className="text-xs text-green-600">已签字</span>
|
||||
@@ -226,6 +226,7 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
|
||||
severity: record?.severity || 'WARNING',
|
||||
action: record?.action || 'ORAL_WARNING',
|
||||
actionDetail: record?.actionDetail || '',
|
||||
deductionAmount: record?.deductionAmount || 0,
|
||||
witness: record?.witness || '',
|
||||
})
|
||||
|
||||
@@ -289,8 +290,14 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
|
||||
</div>
|
||||
<div>
|
||||
<Label>处罚执行细节</Label>
|
||||
<textarea value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} rows={3} className="w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="详细说明处罚执行情况,如扣款金额、书面警告文号、降职后岗位等" />
|
||||
<textarea value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} rows={3} className="w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="详细说明处罚执行情况,如书面警告文号、降职后岗位等" />
|
||||
</div>
|
||||
{form.action === 'DEDUCTION' && (
|
||||
<div>
|
||||
<Label>扣款金额(元)</Label>
|
||||
<Input type="number" value={form.deductionAmount} onChange={(e) => setForm({ ...form, deductionAmount: Number(e.target.value) || 0 })} placeholder="扣款金额,用于薪资批次导入" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>见证人</Label>
|
||||
<Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" />
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
|
||||
{(activeTab) => (
|
||||
<>
|
||||
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
||||
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||||
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} employee={profile} />}
|
||||
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
|
||||
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||||
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
||||
|
||||
@@ -20,19 +20,25 @@ export function SalaryChangeModal({ employee, onClose, onSubmit, loading, error
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const [form, setForm] = useState({
|
||||
newSalary: '',
|
||||
newBaseSalary: '',
|
||||
newPerformanceSalary: '',
|
||||
effectiveDate: todayStr,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const base = parseFloat(form.newBaseSalary) || 0
|
||||
const perf = parseFloat(form.newPerformanceSalary) || 0
|
||||
onSubmit({
|
||||
newSalary: parseFloat(form.newSalary),
|
||||
newSalary: base + perf,
|
||||
baseSalary: base,
|
||||
performanceSalary: perf,
|
||||
effectiveDate: new Date(form.effectiveDate).toISOString(),
|
||||
reason: form.reason || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const canSubmit = form.newSalary && parseFloat(form.newSalary) > 0 && form.effectiveDate
|
||||
const canSubmit = form.newBaseSalary && parseFloat(form.newBaseSalary) > 0 && form.effectiveDate
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`调薪 - ${employee.name}`}>
|
||||
@@ -43,20 +49,39 @@ export function SalaryChangeModal({ employee, onClose, onSubmit, loading, error
|
||||
<div className="text-xs text-gray-600 py-1.5">{employee.name} - {employee.department}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>当前月薪</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">¥{fmt(employee.monthlySalary)}</div>
|
||||
<Label>当前工资</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">
|
||||
{employee.baseSalary != null || employee.performanceSalary != null
|
||||
? `基本 ¥${fmt(employee.baseSalary || 0)} / 绩效 ¥${fmt(employee.performanceSalary || 0)} / 合计 ¥${fmt(employee.monthlySalary)}`
|
||||
: `¥${fmt(employee.monthlySalary)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>新月薪 *</Label>
|
||||
<Input type="number" value={form.newSalary} onChange={(e) => setForm({ ...form, newSalary: e.target.value })} placeholder="元" />
|
||||
<Label>新基本工资 *</Label>
|
||||
<Input type="number" value={form.newBaseSalary} onChange={(e) => {
|
||||
const base = parseFloat(e.target.value) || 0
|
||||
const perf = parseFloat(form.newPerformanceSalary) || 0
|
||||
setForm({ ...form, newBaseSalary: e.target.value, newSalary: String(base + perf) })
|
||||
}} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>新绩效工资</Label>
|
||||
<Input type="number" value={form.newPerformanceSalary} onChange={(e) => {
|
||||
const perf = parseFloat(e.target.value) || 0
|
||||
const base = parseFloat(form.newBaseSalary) || 0
|
||||
setForm({ ...form, newPerformanceSalary: e.target.value, newSalary: String(base + perf) })
|
||||
}} placeholder="可为0" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>生效日期 *</Label>
|
||||
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{(parseFloat(form.newBaseSalary) || 0) + (parseFloat(form.newPerformanceSalary) || 0) > 0 && (
|
||||
<div className="text-xs text-gray-500">新月薪合计:¥{(parseFloat(form.newBaseSalary) || 0) + (parseFloat(form.newPerformanceSalary) || 0)}</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>调薪原因(选填)</Label>
|
||||
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" />
|
||||
@@ -336,19 +361,27 @@ export function ConfirmModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
error: any
|
||||
}) {
|
||||
const originalSalary = employee.monthlySalary ? Number(employee.monthlySalary) : null
|
||||
const originalBaseSalary = employee.baseSalary != null ? Number(employee.baseSalary) : null
|
||||
const originalPerformanceSalary = employee.performanceSalary != null ? Number(employee.performanceSalary) : null
|
||||
const [form, setForm] = useState({
|
||||
confirmDate: new Date().toISOString().slice(0, 10),
|
||||
regularSalary: originalSalary ? String(originalSalary) : '',
|
||||
regularBaseSalary: originalBaseSalary != null ? String(originalBaseSalary) : '',
|
||||
regularPerformanceSalary: originalPerformanceSalary != null ? String(originalPerformanceSalary) : '0',
|
||||
})
|
||||
|
||||
/** 转正薪资与原薪资是否不同 */
|
||||
const salaryChanged = form.regularSalary !== '' && Number(form.regularSalary) !== originalSalary
|
||||
|
||||
const handleSubmit = () => {
|
||||
const base = parseFloat(form.regularBaseSalary) || 0
|
||||
const perf = parseFloat(form.regularPerformanceSalary) || 0
|
||||
onSubmit({
|
||||
employeeId: employee.id,
|
||||
confirmDate: new Date(form.confirmDate).toISOString(),
|
||||
regularSalary: form.regularSalary ? Number(form.regularSalary) : undefined,
|
||||
regularSalary: base + perf,
|
||||
baseSalary: base,
|
||||
performanceSalary: perf,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -366,16 +399,33 @@ export function ConfirmModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
<Label>转正日期 *</Label>
|
||||
<Input type="date" value={form.confirmDate} onChange={(e) => setForm({ ...form, confirmDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>转正薪资{originalSalary ? `(原薪资 ¥${originalSalary})` : ''}</Label>
|
||||
<Input type="number" value={form.regularSalary} onChange={(e) => setForm({ ...form, regularSalary: e.target.value })} placeholder={originalSalary ? `不填则保持原薪资 ¥${originalSalary}` : '请输入转正薪资'} />
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{originalSalary
|
||||
? (salaryChanged
|
||||
? <span className="text-amber-600">⚠ 转正薪资与原薪资不同,提交后将发起薪酬调整确认书签署</span>
|
||||
: '填写后将更新员工月工资并记录薪资变更')
|
||||
: '填写后将更新员工月工资并记录薪资变更'}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>转正基本工资 *</Label>
|
||||
<Input type="number" value={form.regularBaseSalary} onChange={(e) => {
|
||||
const base = parseFloat(e.target.value) || 0
|
||||
const perf = parseFloat(form.regularPerformanceSalary) || 0
|
||||
setForm({ ...form, regularBaseSalary: e.target.value, regularSalary: String(base + perf) })
|
||||
}} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>转正绩效工资</Label>
|
||||
<Input type="number" value={form.regularPerformanceSalary} onChange={(e) => {
|
||||
const perf = parseFloat(e.target.value) || 0
|
||||
const base = parseFloat(form.regularBaseSalary) || 0
|
||||
setForm({ ...form, regularPerformanceSalary: e.target.value, regularSalary: String(base + perf) })
|
||||
}} placeholder="可为0" />
|
||||
</div>
|
||||
</div>
|
||||
{(parseFloat(form.regularBaseSalary) || 0) + (parseFloat(form.regularPerformanceSalary) || 0) > 0 && (
|
||||
<div className="text-xs text-gray-500">转正月薪合计:¥{(parseFloat(form.regularBaseSalary) || 0) + (parseFloat(form.regularPerformanceSalary) || 0)}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{originalSalary
|
||||
? (salaryChanged
|
||||
? <span className="text-amber-600">⚠ 转正薪资与原薪资不同,提交后将发起薪酬调整确认书签署</span>
|
||||
: '填写后将更新员工月工资并记录薪资变更')
|
||||
: '填写后将更新员工月工资并记录薪资变更'}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-danger">
|
||||
@@ -438,6 +488,9 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
contractYears: 3,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
baseSalary: employee.baseSalary != null ? String(employee.baseSalary) : '',
|
||||
performanceSalary: employee.performanceSalary != null ? String(employee.performanceSalary) : '0',
|
||||
monthlySalary: employee.monthlySalary ? String(employee.monthlySalary) : '',
|
||||
socialInsBase: '', socialInsStartMonth: '',
|
||||
housingFundBase: '', housingFundStartMonth: '',
|
||||
})
|
||||
@@ -470,11 +523,14 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
})()
|
||||
|
||||
const monthlySalaryNum = employee?.monthlySalary || 0
|
||||
const baseSalaryNum = parseFloat(form.baseSalary) || 0
|
||||
const probationSalaryError = (() => {
|
||||
if (form.probationMonths <= 0) return ''
|
||||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||||
// 试用期工资不得低于基本工资的80%
|
||||
const refSalary = baseSalaryNum > 0 ? baseSalaryNum : monthlySalaryNum
|
||||
if (refSalary > 0 && form.probationSalary < refSalary * 0.8) {
|
||||
return `试用期工资不得低于基本工资的80%(最低¥${(refSalary * 0.8).toFixed(0)})`
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
@@ -518,9 +574,14 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const base = parseFloat(form.baseSalary) || 0
|
||||
const perf = parseFloat(form.performanceSalary) || 0
|
||||
const data: any = {
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
department: form.department,
|
||||
baseSalary: base,
|
||||
performanceSalary: perf,
|
||||
monthlySalary: base + perf,
|
||||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||||
@@ -535,6 +596,8 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
contractYears: form.contractYears,
|
||||
probationMonths: form.probationMonths,
|
||||
probationSalary: form.probationSalary,
|
||||
baseSalary: base,
|
||||
performanceSalary: perf,
|
||||
}
|
||||
}
|
||||
onSubmit(data)
|
||||
@@ -569,13 +632,35 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
<Label>新入职日期 *</Label>
|
||||
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>基本工资 *</Label>
|
||||
<Input type="number" value={form.baseSalary} onChange={(e) => {
|
||||
const base = parseFloat(e.target.value) || 0
|
||||
const perf = parseFloat(form.performanceSalary) || 0
|
||||
setForm({ ...form, baseSalary: e.target.value, monthlySalary: String(base + perf) })
|
||||
}} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效工资</Label>
|
||||
<Input type="number" value={form.performanceSalary} onChange={(e) => {
|
||||
const perf = parseFloat(e.target.value) || 0
|
||||
const base = parseFloat(form.baseSalary) || 0
|
||||
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: String(base + perf) })
|
||||
}} placeholder="可为0" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>月薪合计</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">¥{(parseFloat(form.baseSalary) || 0) + (parseFloat(form.performanceSalary) || 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金</Label>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与月工资一致,可手动修改</div>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与月薪总额一致,可手动修改</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
|
||||
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月薪总额'} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>社保开始年月</Label>
|
||||
@@ -583,7 +668,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
|
||||
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月薪总额'} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金开始年月</Label>
|
||||
@@ -641,8 +726,8 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
<div>
|
||||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||||
{baseSalaryNum > 0 && form.probationMonths > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">不低于基本工资80%(≥¥{(baseSalaryNum * 0.8).toFixed(0)})</div>
|
||||
)}
|
||||
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
|
||||
</div>
|
||||
@@ -704,6 +789,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
} catch {}
|
||||
return {
|
||||
name: '', department: '', departmentId: '', position: '', hireDate: todayStr, monthlySalary: '',
|
||||
baseSalary: '', performanceSalary: '0',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||||
city: '北京', education: '', status: 'ACTIVE',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
@@ -900,11 +986,14 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
})()
|
||||
|
||||
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
|
||||
const baseSalaryNum = parseFloat(form.baseSalary) || 0
|
||||
const probationSalaryError = (() => {
|
||||
if (form.probationMonths <= 0) return ''
|
||||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||||
// 试用期工资不得低于基本工资的80%
|
||||
const refSalary = baseSalaryNum > 0 ? baseSalaryNum : monthlySalaryNum
|
||||
if (refSalary > 0 && form.probationSalary < refSalary * 0.8) {
|
||||
return `试用期工资不得低于基本工资的80%(最低¥${(refSalary * 0.8).toFixed(0)})`
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
@@ -944,7 +1033,10 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
name: form.name, department: form.department, departmentId: form.departmentId || undefined,
|
||||
position: form.position || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary, gender: form.gender,
|
||||
monthlySalary: form.monthlySalary,
|
||||
baseSalary: form.baseSalary,
|
||||
performanceSalary: form.performanceSalary || '0',
|
||||
gender: form.gender,
|
||||
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
|
||||
idCardNumber: form.idCardNumber || undefined,
|
||||
phone: form.phone || undefined,
|
||||
@@ -971,7 +1063,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
const canSubmit = form.name && form.department && form.hireDate && form.monthlySalary
|
||||
const canSubmit = form.name && form.department && form.hireDate && form.baseSalary
|
||||
&& form.idCardNumber.length >= 18
|
||||
&& ageWarning?.type !== 'BLOCK'
|
||||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||||
@@ -979,7 +1071,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
// 超龄人员不得签订劳动合同
|
||||
&& (!isOverage || ['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(form.contractType))
|
||||
|
||||
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
|
||||
const isDirty = !!(form.name || form.department || form.idCardNumber || form.baseSalary || form.phone)
|
||||
useUnsavedChanges(isDirty)
|
||||
|
||||
return (
|
||||
@@ -1022,7 +1114,27 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>入职日期 *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
|
||||
<div><Label>月工资 *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
|
||||
<div>
|
||||
<Label>基本工资 *</Label>
|
||||
<Input type="number" value={form.baseSalary} onChange={(e) => {
|
||||
const base = e.target.value
|
||||
const perf = parseFloat(form.performanceSalary) || 0
|
||||
const total = (parseFloat(base) || 0) + perf
|
||||
setForm({ ...form, baseSalary: base, monthlySalary: total > 0 ? String(total) : '' })
|
||||
}} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效工资</Label>
|
||||
<Input type="number" value={form.performanceSalary} onChange={(e) => {
|
||||
const perf = e.target.value
|
||||
const base = parseFloat(form.baseSalary) || 0
|
||||
const total = base + (parseFloat(perf) || 0)
|
||||
setForm({ ...form, performanceSalary: perf, monthlySalary: total > 0 ? String(total) : '' })
|
||||
}} placeholder="可为0" />
|
||||
{(parseFloat(form.baseSalary) || 0) + (parseFloat(form.performanceSalary) || 0) > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">月薪合计 ¥{(parseFloat(form.baseSalary) || 0) + (parseFloat(form.performanceSalary) || 0)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => {
|
||||
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
|
||||
setForm({ ...form, phone })
|
||||
@@ -1051,7 +1163,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
{isNoSocialContract ? (
|
||||
<span className="text-xs text-amber-600">劳务协议/实习协议人员不缴纳社保公积金</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">选定部门后自动带出账户,可手动调整。缴费基数默认与月工资一致。</span>
|
||||
<span className="text-xs text-gray-500">选定部门后自动带出账户,可手动调整。缴费基数默认与月薪总额一致。</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 账户选择(选部门后自动带出,可手动调整) */}
|
||||
@@ -1193,8 +1305,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div>
|
||||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||||
{baseSalaryNum > 0 && form.probationMonths > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">不低于基本工资80%(≥¥{(baseSalaryNum * 0.8).toFixed(0)})</div>
|
||||
)}
|
||||
{probationSalaryError && <div className="text-xs text-danger mt-1">{probationSalaryError}</div>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user