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 字符串)
|
departmentId String? // 关联 Department 表(兼容旧 department 字符串)
|
||||||
supervisorId String? // 直属上级员工ID
|
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)
|
status EmployeeStatus @default(ACTIVE)
|
||||||
gender String?
|
gender String?
|
||||||
phone String?
|
phone String?
|
||||||
@@ -348,6 +350,8 @@ model LaborContract {
|
|||||||
contractYears Int @default(3)
|
contractYears Int @default(3)
|
||||||
probationMonths Int @default(0)
|
probationMonths Int @default(0)
|
||||||
probationSalary Int @default(0)
|
probationSalary Int @default(0)
|
||||||
|
baseSalary Int @default(0) // 基本工资(转正后)
|
||||||
|
performanceSalary Int @default(0) // 绩效工资(转正后,可为0)
|
||||||
renewalCount Int @default(0)
|
renewalCount Int @default(0)
|
||||||
attachmentName String?
|
attachmentName String?
|
||||||
attachmentUrl String?
|
attachmentUrl String?
|
||||||
@@ -713,6 +717,7 @@ model DisciplinaryRecord {
|
|||||||
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
||||||
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
||||||
actionDetail String?
|
actionDetail String?
|
||||||
|
deductionAmount Float @default(0) // 扣款金额(action=DEDUCTION 时填写,用于薪资批次导入)
|
||||||
employeeAck Boolean @default(false) // 员工是否签字确认
|
employeeAck Boolean @default(false) // 员工是否签字确认
|
||||||
ackDate DateTime?
|
ackDate DateTime?
|
||||||
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
||||||
@@ -820,6 +825,7 @@ model Payslip {
|
|||||||
month String // YYYY-MM
|
month String // YYYY-MM
|
||||||
// 薪酬构成
|
// 薪酬构成
|
||||||
baseSalary Float @default(0)
|
baseSalary Float @default(0)
|
||||||
|
performanceSalary Float @default(0) // 绩效工资
|
||||||
overtimePay Float @default(0)
|
overtimePay Float @default(0)
|
||||||
weekdayOvertimePay Float @default(0)
|
weekdayOvertimePay Float @default(0)
|
||||||
weekendOvertimePay Float @default(0)
|
weekendOvertimePay Float @default(0)
|
||||||
@@ -1194,6 +1200,7 @@ model AttendanceConfirmation {
|
|||||||
weekendHours Float @default(0)
|
weekendHours Float @default(0)
|
||||||
holidayHours Float @default(0)
|
holidayHours Float @default(0)
|
||||||
overtimePay Float @default(0)
|
overtimePay Float @default(0)
|
||||||
|
deductionAmount Float @default(0) // 考勤扣款金额(迟到/早退/旷工等,手动填写,用于薪资批次导入)
|
||||||
confirmedAt DateTime?
|
confirmedAt DateTime?
|
||||||
confirmedBy String?
|
confirmedBy String?
|
||||||
confirmIp String?
|
confirmIp String?
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, ne
|
|||||||
weekendHours: z.number().min(0),
|
weekendHours: z.number().min(0),
|
||||||
holidayHours: z.number().min(0),
|
holidayHours: z.number().min(0),
|
||||||
overtimePay: z.number().min(0),
|
overtimePay: z.number().min(0),
|
||||||
|
deductionAmount: z.number().min(0).optional(),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
const { month, items } = schema.parse(req.body)
|
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) }
|
} 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) => {
|
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 }[] = []
|
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
|
||||||
for (const emp of employees) {
|
for (const emp of employees) {
|
||||||
let baseSalary = 0
|
let baseSalary = 0
|
||||||
|
let performanceSalary = 0
|
||||||
let overtimePay = 0
|
let overtimePay = 0
|
||||||
let allowance = 0
|
let allowance = 0
|
||||||
let deduction = 0
|
let deduction = 0
|
||||||
let bonus = 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) {
|
if (mode === 'copy_batch' && sourceEntries) {
|
||||||
// 复制指定批次:从源条目复制数据
|
// 复制指定批次:从源条目复制数据
|
||||||
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
||||||
if (srcEntry) {
|
if (srcEntry) {
|
||||||
baseSalary = srcEntry.baseSalary
|
baseSalary = srcEntry.baseSalary
|
||||||
|
performanceSalary = srcEntry.performanceSalary || 0
|
||||||
overtimePay = srcEntry.overtimePay
|
overtimePay = srcEntry.overtimePay
|
||||||
allowance = srcEntry.allowance
|
allowance = srcEntry.allowance
|
||||||
deduction = srcEntry.deduction
|
deduction = srcEntry.deduction
|
||||||
@@ -397,17 +409,40 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
const inProbation = isInProbation(latestContract, batchMonthEnd)
|
const inProbation = isInProbation(latestContract, batchMonthEnd)
|
||||||
if (inProbation && latestContract.probationSalary > 0) {
|
if (inProbation && latestContract.probationSalary > 0) {
|
||||||
baseSalary = latestContract.probationSalary
|
baseSalary = latestContract.probationSalary
|
||||||
|
performanceSalary = 0 // 试用期一般不发绩效
|
||||||
} else if (prevPayslip) {
|
} else if (prevPayslip) {
|
||||||
// 非试用期:优先用上月工资条的基本工资(保持薪资连续性)
|
// 非试用期:优先用上月工资条的基本工资(保持薪资连续性)
|
||||||
baseSalary = prevPayslip.baseSalary
|
baseSalary = prevPayslip.baseSalary
|
||||||
|
performanceSalary = prevPayslip.performanceSalary || 0
|
||||||
|
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
|
||||||
|
// 新数据:有工资结构
|
||||||
|
baseSalary = empBaseSalary
|
||||||
|
performanceSalary = empPerformanceSalary
|
||||||
} else if (emp.monthlySalary) {
|
} else if (emp.monthlySalary) {
|
||||||
|
// 旧数据兼容:回退到 monthlySalary
|
||||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||||
}
|
}
|
||||||
overtimePay = overtime?.totalPay || 0
|
overtimePay = overtime?.totalPay || 0
|
||||||
allowance = prevPayslip?.allowance || 0
|
allowance = prevPayslip?.allowance || 0
|
||||||
deduction = prevPayslip?.deduction || 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 和 blank_all: 所有金额默认 0(不自动带出基本工资,不取试用期工资)
|
// blank_employees 模式:金额默认0,不自动带出
|
||||||
|
}
|
||||||
|
// blank_all: 所有金额默认 0
|
||||||
|
|
||||||
// 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外)
|
// 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外)
|
||||||
// blank_employees / blank_all 模式下所有金额应为 0,不覆盖试用期工资
|
// blank_employees / blank_all 模式下所有金额应为 0,不覆盖试用期工资
|
||||||
@@ -416,6 +451,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
const latestContract = emp.contracts?.[0]
|
const latestContract = emp.contracts?.[0]
|
||||||
if (isInProbation(latestContract, batchMonthEnd) && latestContract?.probationSalary > 0) {
|
if (isInProbation(latestContract, batchMonthEnd) && latestContract?.probationSalary > 0) {
|
||||||
baseSalary = latestContract.probationSalary
|
baseSalary = latestContract.probationSalary
|
||||||
|
performanceSalary = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +478,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
try {
|
try {
|
||||||
// 查询上月递延数据(上月已归档批次中该员工的递延金额)
|
// 查询上月递延数据(上月已归档批次中该员工的递延金额)
|
||||||
const prevDeferred = await getPrevDeferred(orgId, emp.id, month)
|
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) {
|
} catch (calcErr: any) {
|
||||||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
||||||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
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,
|
orgId,
|
||||||
employeeId: emp.id,
|
employeeId: emp.id,
|
||||||
baseSalary,
|
baseSalary,
|
||||||
|
performanceSalary,
|
||||||
overtimePay,
|
overtimePay,
|
||||||
allowance,
|
allowance,
|
||||||
deduction,
|
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) => {
|
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
idCardMasked,
|
idCardMasked,
|
||||||
idCardNumber: safeDecryptStr(e.idCardNumber),
|
idCardNumber: safeDecryptStr(e.idCardNumber),
|
||||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||||
|
baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null,
|
||||||
|
performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null,
|
||||||
socialInsBase: e.socialInsBase,
|
socialInsBase: e.socialInsBase,
|
||||||
housingFundBase: e.housingFundBase,
|
housingFundBase: e.housingFundBase,
|
||||||
socialInsCalc: (() => {
|
socialInsCalc: (() => {
|
||||||
@@ -373,7 +375,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
|||||||
if (found) monthlyProcessRecords.push(recordData)
|
if (found) monthlyProcessRecords.push(recordData)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
|
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,
|
...rest,
|
||||||
status: dynamicStatus,
|
status: dynamicStatus,
|
||||||
monthlySalary: safeDecrypt(monthlySalary),
|
monthlySalary: safeDecrypt(monthlySalary),
|
||||||
|
baseSalary: baseSalary ? safeDecrypt(baseSalary) : null,
|
||||||
|
performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null,
|
||||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||||
idCardNumber: safeDecryptStr(idCardNumber),
|
idCardNumber: safeDecryptStr(idCardNumber),
|
||||||
monthlyProcessRecords,
|
monthlyProcessRecords,
|
||||||
@@ -961,7 +965,7 @@ router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest,
|
|||||||
|
|
||||||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
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({
|
const record = await prisma.disciplinaryRecord.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
@@ -972,6 +976,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
|||||||
severity: severity || 'WARNING',
|
severity: severity || 'WARNING',
|
||||||
action: action || 'ORAL_WARNING',
|
action: action || 'ORAL_WARNING',
|
||||||
actionDetail,
|
actionDetail,
|
||||||
|
deductionAmount: Number(deductionAmount) || 0,
|
||||||
employeeAck: employeeAck || false,
|
employeeAck: employeeAck || false,
|
||||||
ackDate: ackDate ? new Date(ackDate) : null,
|
ackDate: ackDate ? new Date(ackDate) : null,
|
||||||
ackMethod,
|
ackMethod,
|
||||||
@@ -995,7 +1000,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
|||||||
|
|
||||||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
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({
|
const record = await prisma.disciplinaryRecord.findFirst({
|
||||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||||
})
|
})
|
||||||
@@ -1009,6 +1014,7 @@ router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: Au
|
|||||||
severity,
|
severity,
|
||||||
action,
|
action,
|
||||||
actionDetail,
|
actionDetail,
|
||||||
|
deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined,
|
||||||
employeeAck,
|
employeeAck,
|
||||||
ackDate: ackDate ? new Date(ackDate) : null,
|
ackDate: ackDate ? new Date(ackDate) : null,
|
||||||
ackMethod,
|
ackMethod,
|
||||||
@@ -1412,7 +1418,7 @@ function prevMonth(month: string): string {
|
|||||||
// 调薪
|
// 调薪
|
||||||
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { newSalary, effectiveMonth, reason } = req.body
|
const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body
|
||||||
const employee = await prisma.employee.findFirst({
|
const employee = await prisma.employee.findFirst({
|
||||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
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({
|
await prisma.employee.update({
|
||||||
where: { id: req.params.id },
|
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 })
|
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
|
weekendHours: number
|
||||||
holidayHours: number
|
holidayHours: number
|
||||||
overtimePay: number
|
overtimePay: number
|
||||||
|
deductionAmount?: number
|
||||||
}>) {
|
}>) {
|
||||||
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
|
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
|||||||
weekendHours: item.weekendHours,
|
weekendHours: item.weekendHours,
|
||||||
holidayHours: item.holidayHours,
|
holidayHours: item.holidayHours,
|
||||||
overtimePay: item.overtimePay,
|
overtimePay: item.overtimePay,
|
||||||
|
deductionAmount: item.deductionAmount || 0,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -80,6 +82,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
|||||||
weekendHours: item.weekendHours,
|
weekendHours: item.weekendHours,
|
||||||
holidayHours: item.holidayHours,
|
holidayHours: item.holidayHours,
|
||||||
overtimePay: item.overtimePay,
|
overtimePay: item.overtimePay,
|
||||||
|
deductionAmount: item.deductionAmount || 0,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ function dateToMonth(date: Date): string {
|
|||||||
return `${y}-${m}`
|
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> {
|
async function clampSocialInsBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
|
||||||
// 优先按账户查年度标准
|
// 优先按账户查年度标准
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
@@ -271,6 +280,9 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
|||||||
} catch {
|
} catch {
|
||||||
decryptedSalary = Number(emp.monthlySalary) || 0
|
decryptedSalary = Number(emp.monthlySalary) || 0
|
||||||
}
|
}
|
||||||
|
// 解密工资结构
|
||||||
|
const decryptedBaseSalary = emp.baseSalary ? safeDecryptNum(emp.baseSalary) : null
|
||||||
|
const decryptedPerformanceSalary = emp.performanceSalary ? safeDecryptNum(emp.performanceSalary) : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: emp.id,
|
id: emp.id,
|
||||||
@@ -279,6 +291,8 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
|||||||
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
||||||
status: emp.status,
|
status: emp.status,
|
||||||
monthlySalary: decryptedSalary,
|
monthlySalary: decryptedSalary,
|
||||||
|
baseSalary: decryptedBaseSalary,
|
||||||
|
performanceSalary: decryptedPerformanceSalary,
|
||||||
contractStatus: contractInfo.status,
|
contractStatus: contractInfo.status,
|
||||||
contractStatusText: contractInfo.statusText,
|
contractStatusText: contractInfo.statusText,
|
||||||
riskLevel: contractInfo.riskLevel,
|
riskLevel: contractInfo.riskLevel,
|
||||||
@@ -315,6 +329,8 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
|||||||
return {
|
return {
|
||||||
...employee,
|
...employee,
|
||||||
monthlySalary: decryptedSalary,
|
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 hireDate = new Date(data.hireDate)
|
||||||
const hireMonth = dateToMonth(hireDate)
|
const hireMonth = dateToMonth(hireDate)
|
||||||
const salaryNum = Number(data.monthlySalary) || 0
|
const salaryNum = Number(data.monthlySalary) || 0
|
||||||
|
// 工资结构:基本工资 + 绩效工资 = 月薪总额
|
||||||
|
const baseSalaryNum = Number(data.baseSalary) || 0
|
||||||
|
const performanceSalaryNum = Number(data.performanceSalary) || 0
|
||||||
const city = data.city || '北京'
|
const city = data.city || '北京'
|
||||||
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
|
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
|
||||||
const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
|
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,
|
department: data.department,
|
||||||
hireDate,
|
hireDate,
|
||||||
monthlySalary: encrypt(data.monthlySalary),
|
monthlySalary: encrypt(data.monthlySalary),
|
||||||
|
baseSalary: encrypt(String(baseSalaryNum)),
|
||||||
|
performanceSalary: encrypt(String(performanceSalaryNum)),
|
||||||
gender: data.gender,
|
gender: data.gender,
|
||||||
femaleWorkerType: data.femaleWorkerType,
|
femaleWorkerType: data.femaleWorkerType,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
@@ -488,6 +509,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
contractYears: data.contract.contractYears || 3,
|
contractYears: data.contract.contractYears || 3,
|
||||||
probationMonths: data.contract.probationMonths || 0,
|
probationMonths: data.contract.probationMonths || 0,
|
||||||
probationSalary: data.contract.probationSalary || 0,
|
probationSalary: data.contract.probationSalary || 0,
|
||||||
|
baseSalary: baseSalaryNum,
|
||||||
|
performanceSalary: performanceSalaryNum,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -526,6 +549,10 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
|||||||
|
|
||||||
const newHireMonth = dateToMonth(newHireDate)
|
const newHireMonth = dateToMonth(newHireDate)
|
||||||
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
|
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 city = data.city || employee.city || '北京'
|
||||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : 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,
|
housingFundStartMonth,
|
||||||
housingFundEndMonth: null,
|
housingFundEndMonth: null,
|
||||||
city: data.city || employee.city || '北京',
|
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,
|
contractYears: data.contract.contractYears || 3,
|
||||||
probationMonths: data.contract.probationMonths || 0,
|
probationMonths: data.contract.probationMonths || 0,
|
||||||
probationSalary: data.contract.probationSalary || 0,
|
probationSalary: data.contract.probationSalary || 0,
|
||||||
|
baseSalary: baseSalaryNum,
|
||||||
|
performanceSalary: performanceSalaryNum,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -702,6 +737,13 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
|||||||
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
|
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||||
const newSalary = Number(data.monthlySalary) || 0
|
const newSalary = Number(data.monthlySalary) || 0
|
||||||
updateData.monthlySalary = encrypt(data.monthlySalary)
|
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) {
|
if (oldSalary !== newSalary) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -912,6 +954,8 @@ export async function batchRenew(orgId: string, userId: string, contractIds: str
|
|||||||
contractYears: years,
|
contractYears: years,
|
||||||
probationMonths: 0,
|
probationMonths: 0,
|
||||||
probationSalary: 0,
|
probationSalary: 0,
|
||||||
|
baseSalary: contract.baseSalary || 0,
|
||||||
|
performanceSalary: contract.performanceSalary || 0,
|
||||||
renewalCount: contract.renewalCount + 1,
|
renewalCount: contract.renewalCount + 1,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
@@ -963,6 +1007,8 @@ export async function addContract(orgId: string, userId: string, data: any) {
|
|||||||
contractYears: data.contractYears || 3,
|
contractYears: data.contractYears || 3,
|
||||||
probationMonths: data.probationMonths || 0,
|
probationMonths: data.probationMonths || 0,
|
||||||
probationSalary: data.probationSalary || 0,
|
probationSalary: data.probationSalary || 0,
|
||||||
|
baseSalary: data.baseSalary || 0,
|
||||||
|
performanceSalary: data.performanceSalary || 0,
|
||||||
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
||||||
attachmentUrl: data.attachmentUrl || null,
|
attachmentUrl: data.attachmentUrl || null,
|
||||||
electronicContractNo: data.electronicContractNo || null,
|
electronicContractNo: data.electronicContractNo || null,
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
|||||||
where: { employeeId_month: { employeeId, month } },
|
where: { employeeId_month: { employeeId, month } },
|
||||||
update: {
|
update: {
|
||||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||||
|
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||||
allowance: Math.round(summary.allowance * 100) / 100,
|
allowance: Math.round(summary.allowance * 100) / 100,
|
||||||
deduction: Math.round(summary.deduction * 100) / 100,
|
deduction: Math.round(summary.deduction * 100) / 100,
|
||||||
@@ -659,6 +660,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
|||||||
employeeId,
|
employeeId,
|
||||||
month,
|
month,
|
||||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||||
|
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||||
allowance: Math.round(summary.allowance * 100) / 100,
|
allowance: Math.round(summary.allowance * 100) / 100,
|
||||||
deduction: Math.round(summary.deduction * 100) / 100,
|
deduction: Math.round(summary.deduction * 100) / 100,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
name: '固定期限劳动合同',
|
name: '固定期限劳动合同',
|
||||||
category: 'CONTRACT',
|
category: 'CONTRACT',
|
||||||
description: '标准固定期限劳动合同模板,适用于大多数正式员工',
|
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: `劳动合同书
|
content: `劳动合同书
|
||||||
|
|
||||||
甲方(用人单位):{{companyName}}
|
甲方(用人单位):{{companyName}}
|
||||||
@@ -36,7 +36,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
|
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
|
||||||
|
|
||||||
第四条 劳动报酬
|
第四条 劳动报酬
|
||||||
乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。
|
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。试用期月工资为人民币{{probationSalary}}元。甲方于每月15日前支付上月工资。
|
||||||
|
|
||||||
第五条 社会保险和福利
|
第五条 社会保险和福利
|
||||||
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
|
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
|
||||||
@@ -64,7 +64,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
name: '无固定期限劳动合同',
|
name: '无固定期限劳动合同',
|
||||||
category: 'CONTRACT',
|
category: 'CONTRACT',
|
||||||
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
|
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
|
||||||
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'],
|
variables: ['companyName', 'employeeName', 'startDate', 'position', 'baseSalary', 'performanceSalary'],
|
||||||
content: `无固定期限劳动合同书
|
content: `无固定期限劳动合同书
|
||||||
|
|
||||||
甲方(用人单位):{{companyName}}
|
甲方(用人单位):{{companyName}}
|
||||||
@@ -79,7 +79,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
乙方担任{{position}}岗位。
|
乙方担任{{position}}岗位。
|
||||||
|
|
||||||
第三条 劳动报酬
|
第三条 劳动报酬
|
||||||
乙方月工资为人民币{{monthlySalary}}元。
|
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。
|
||||||
|
|
||||||
(其余条款参照固定期限劳动合同模板)
|
(其余条款参照固定期限劳动合同模板)
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ ____年__月__日
|
|||||||
name: '试用期转正通知书',
|
name: '试用期转正通知书',
|
||||||
category: 'NOTICE',
|
category: 'NOTICE',
|
||||||
description: '试用期考核合格转正通知',
|
description: '试用期考核合格转正通知',
|
||||||
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'],
|
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'baseSalary', 'performanceSalary', 'companyName'],
|
||||||
content: `试用期转正通知书
|
content: `试用期转正通知书
|
||||||
|
|
||||||
{{employeeName}}:
|
{{employeeName}}:
|
||||||
@@ -249,7 +249,7 @@ ____年__月__日
|
|||||||
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
|
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
|
||||||
|
|
||||||
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
|
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
|
||||||
二、转正后月工资为人民币{{monthlySalary}}元。
|
二、转正后基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月。
|
||||||
三、试用期至{{probationEndDate}}结束。
|
三、试用期至{{probationEndDate}}结束。
|
||||||
|
|
||||||
请继续遵守公司各项规章制度,努力工作。
|
请继续遵守公司各项规章制度,努力工作。
|
||||||
|
|||||||
@@ -66,15 +66,19 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
|||||||
return { employeeId }
|
return { employeeId }
|
||||||
}
|
}
|
||||||
case 'CONFIRM': {
|
case 'CONFIRM': {
|
||||||
const { employeeId, regularSalary } = formData
|
const { employeeId, regularSalary, baseSalary, performanceSalary } = formData
|
||||||
if (employeeId) {
|
if (employeeId) {
|
||||||
if (regularSalary) {
|
if (regularSalary) {
|
||||||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||||||
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
|
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
|
||||||
const newSalary = Number(regularSalary) || 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({
|
await prisma.employee.update({
|
||||||
where: { id: employeeId },
|
where: { id: employeeId },
|
||||||
data: { monthlySalary: encrypt(String(regularSalary)) },
|
data: updateData,
|
||||||
})
|
})
|
||||||
// 记录薪资变更(试用期薪资 → 转正薪资)
|
// 记录薪资变更(试用期薪资 → 转正薪资)
|
||||||
if (oldSalary !== newSalary) {
|
if (oldSalary !== newSalary) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const ForgotPassword = lazyRetry(() => import('./pages/auth/ForgotPassword'))
|
|||||||
const Dashboard = lazyRetry(() => import('./pages/Dashboard'))
|
const Dashboard = lazyRetry(() => import('./pages/Dashboard'))
|
||||||
const Money = lazyRetry(() => import('./pages/Money'))
|
const Money = lazyRetry(() => import('./pages/Money'))
|
||||||
const SocialInsurance = lazyRetry(() => import('./pages/SocialInsurance'))
|
const SocialInsurance = lazyRetry(() => import('./pages/SocialInsurance'))
|
||||||
|
const Overtime = lazyRetry(() => import('./pages/Overtime'))
|
||||||
const Roster = lazyRetry(() => import('./pages/Roster'))
|
const Roster = lazyRetry(() => import('./pages/Roster'))
|
||||||
const OrgChart = lazyRetry(() => import('./pages/OrgChart'))
|
const OrgChart = lazyRetry(() => import('./pages/OrgChart'))
|
||||||
const SupportDashboard = lazyRetry(() => import('./pages/support/SupportDashboard'))
|
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 }) {
|
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) {
|
if (!showNav) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface pt-safe pb-safe">
|
<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="/support" element={<ProtectedRoute><AdminLayout><SupportDashboard /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
|
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></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="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
|
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
|
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||||
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
||||||
DollarSign,
|
DollarSign, Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { settingsApi } from '../../lib/api-services'
|
import { settingsApi } from '../../lib/api-services'
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||||
|
{ path: '/overtime', label: '加班费计算', icon: Clock },
|
||||||
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
|
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
|
||||||
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
|
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -294,6 +294,12 @@ export const attendanceApi = {
|
|||||||
/** 批量确认 */
|
/** 批量确认 */
|
||||||
batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) =>
|
batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) =>
|
||||||
post('/attendance/batch-confirm', data).then(unwrap<any>()),
|
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 }) =>
|
confirm: (data: { employeeId: string; month: string }) =>
|
||||||
post('/attendance/confirm', data).then(unwrap<any>()),
|
post('/attendance/confirm', data).then(unwrap<any>()),
|
||||||
@@ -480,6 +486,15 @@ export const payrollApi = {
|
|||||||
/** 获取提成奖金到批次 */
|
/** 获取提成奖金到批次 */
|
||||||
fetchBonusToBatch: (batchId: string) =>
|
fetchBonusToBatch: (batchId: string) =>
|
||||||
post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap<any>()),
|
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: () =>
|
overtimeConfig: () =>
|
||||||
get('/payroll/overtime/config').then(unwrap<any>()),
|
get('/payroll/overtime/config').then(unwrap<any>()),
|
||||||
@@ -1096,9 +1111,20 @@ portalAxios.interceptors.request.use((config: any) => {
|
|||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
// 与管理端 api 实例一致:response interceptor 返回 response.data(后端 JSON body)
|
// 与管理端 api 实例一致:response interceptor 返回 response.data(后端 JSON body)
|
||||||
|
// 401 时清除 token 并跳转登录页(避免 token 过期后显示空数据)
|
||||||
portalAxios.interceptors.response.use(
|
portalAxios.interceptors.response.use(
|
||||||
(response) => response.data,
|
(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 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
|
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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [editItem, setEditItem] = useState<any>(null)
|
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 fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const pageSize = usePageSize()
|
const pageSize = usePageSize()
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@@ -193,7 +193,19 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
|||||||
|
|
||||||
const editMutation = useMutation({
|
const editMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success('考勤记录已修改')
|
toast.success('考勤记录已修改')
|
||||||
@@ -421,6 +433,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
|||||||
<span>周末加班 {item.weekendHours}h</span>
|
<span>周末加班 {item.weekendHours}h</span>
|
||||||
<span>法定加班 {item.holidayHours}h</span>
|
<span>法定加班 {item.holidayHours}h</span>
|
||||||
<span className="text-gray-700">加班费 ¥{item.overtimePay?.toFixed(2)}</span>
|
<span className="text-gray-700">加班费 ¥{item.overtimePay?.toFixed(2)}</span>
|
||||||
|
{item.deductionAmount > 0 && <span className="text-danger">扣款 ¥{item.deductionAmount?.toFixed(2)}</span>}
|
||||||
</div>
|
</div>
|
||||||
{item.disputeNote && (
|
{item.disputeNote && (
|
||||||
<div className="text-xs text-red-600 mt-1">异议说明:{item.disputeNote}</div>
|
<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,
|
leaveDays: item.leaveDays || 0,
|
||||||
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
|
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
|
||||||
overtimePay: item.overtimePay || 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}
|
<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) })} />
|
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
|
||||||
</div>
|
</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>
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}>取消</Button>
|
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}>取消</Button>
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
position: '',
|
position: '',
|
||||||
hireDate: '',
|
hireDate: '',
|
||||||
monthlySalary: '',
|
monthlySalary: '',
|
||||||
|
baseSalary: '',
|
||||||
|
performanceSalary: '0',
|
||||||
gender: '男' as '男' | '女',
|
gender: '男' as '男' | '女',
|
||||||
phone: '',
|
phone: '',
|
||||||
isPregnant: false,
|
isPregnant: false,
|
||||||
@@ -261,6 +263,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
position: form.position || undefined,
|
position: form.position || undefined,
|
||||||
hireDate: new Date(form.hireDate).toISOString(),
|
hireDate: new Date(form.hireDate).toISOString(),
|
||||||
monthlySalary: form.monthlySalary,
|
monthlySalary: form.monthlySalary,
|
||||||
|
baseSalary: form.baseSalary,
|
||||||
|
performanceSalary: form.performanceSalary || '0',
|
||||||
gender: form.gender,
|
gender: form.gender,
|
||||||
phone: form.phone || undefined,
|
phone: form.phone || undefined,
|
||||||
isPregnant: form.isPregnant,
|
isPregnant: form.isPregnant,
|
||||||
@@ -276,6 +280,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
contractYears: form.contractYears,
|
contractYears: form.contractYears,
|
||||||
probationMonths: form.probationMonths,
|
probationMonths: form.probationMonths,
|
||||||
probationSalary: form.probationSalary,
|
probationSalary: form.probationSalary,
|
||||||
|
baseSalary: parseFloat(form.baseSalary) || 0,
|
||||||
|
performanceSalary: parseFloat(form.performanceSalary) || 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onSubmit(data)
|
onSubmit(data)
|
||||||
@@ -317,9 +323,27 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>月工资 *</Label>
|
<Label>基本工资 *</Label>
|
||||||
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useState, Suspense } from 'react'
|
import { useState, Suspense } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
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 PageGuide from '../components/ui/PageGuide'
|
||||||
import { lazyRetry } from '../lib/lazyRetry'
|
import { lazyRetry } from '../lib/lazyRetry'
|
||||||
|
|
||||||
const BatchManager = lazyRetry(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
|
const BatchManager = lazyRetry(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
|
||||||
const TemplateManager = lazyRetry(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager })))
|
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 })))
|
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() {
|
export default function Money() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
@@ -20,18 +19,17 @@ export default function Money() {
|
|||||||
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
|
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
|
||||||
{ key: 'template', label: '薪酬模版', icon: <LayoutTemplate 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" /> },
|
{ key: 'payslip', label: '工资条管理', icon: <Receipt className="w-4 h-4" /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageGuide>薪酬管理,支持工资条发放、批量算薪、加班费计算和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理。</PageGuide>
|
<PageGuide>薪酬管理,支持工资条发放、批量算薪和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理,加班费在「加班费计算」模块单独计算后导入。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wallet className="w-5 h-5 text-primary" />
|
<Wallet className="w-5 h-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-semibold">薪税管理</h1>
|
<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>
|
||||||
</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>}>
|
<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 === 'batch' && <BatchManager />}
|
||||||
{tab === 'template' && <TemplateManager />}
|
{tab === 'template' && <TemplateManager />}
|
||||||
{tab === 'overtime' && <OvertimeCalculator />}
|
|
||||||
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
|
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</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 { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
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 { Stepper, type Step } from '../../components/ui/Stepper'
|
||||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||||
import PageGuide from '../../components/ui/PageGuide'
|
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({
|
const importPayrollMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
const token = useAuthStore.getState().accessToken
|
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" />
|
<DollarSign className="w-4 h-4 mr-1" />
|
||||||
{fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'}
|
{fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
phone: profile.phone || '',
|
phone: profile.phone || '',
|
||||||
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
||||||
monthlySalary: profile.monthlySalary || '',
|
monthlySalary: profile.monthlySalary || '',
|
||||||
|
baseSalary: profile.baseSalary != null ? profile.baseSalary : '',
|
||||||
|
performanceSalary: profile.performanceSalary != null ? profile.performanceSalary : '0',
|
||||||
emergencyContact: profile.emergencyContact || '',
|
emergencyContact: profile.emergencyContact || '',
|
||||||
emergencyPhone: profile.emergencyPhone || '',
|
emergencyPhone: profile.emergencyPhone || '',
|
||||||
address: profile.address || '',
|
address: profile.address || '',
|
||||||
@@ -171,6 +173,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
phone: form.phone || undefined,
|
phone: form.phone || undefined,
|
||||||
hireDate: new Date(form.hireDate).toISOString(),
|
hireDate: new Date(form.hireDate).toISOString(),
|
||||||
monthlySalary: String(form.monthlySalary),
|
monthlySalary: String(form.monthlySalary),
|
||||||
|
baseSalary: String(form.baseSalary),
|
||||||
|
performanceSalary: String(form.performanceSalary || '0'),
|
||||||
emergencyContact: form.emergencyContact || undefined,
|
emergencyContact: form.emergencyContact || undefined,
|
||||||
emergencyPhone: form.emergencyPhone || undefined,
|
emergencyPhone: form.emergencyPhone || undefined,
|
||||||
address: form.address || undefined,
|
address: form.address || undefined,
|
||||||
@@ -235,7 +239,9 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
const salaryFields = [
|
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.emergencyContact || '未填写' },
|
||||||
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
|
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
|
||||||
{ label: '住址', value: profile.address || '未填写' },
|
{ 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><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 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="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.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><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>
|
<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 { Input, Label, Select } from "../../components/ui/Input"
|
||||||
import { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download, PenTool } from "lucide-react"
|
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 queryClient = useQueryClient()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
const [showForm, setShowForm] = useState(false)
|
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 contractFileRef = useRef<HTMLInputElement>(null)
|
||||||
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
@@ -200,6 +200,8 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
|||||||
endDate: '',
|
endDate: '',
|
||||||
probationMonths: 0,
|
probationMonths: 0,
|
||||||
probationSalary: 0,
|
probationSalary: 0,
|
||||||
|
baseSalary: employee?.baseSalary != null ? employee.baseSalary : 0,
|
||||||
|
performanceSalary: employee?.performanceSalary != null ? employee.performanceSalary : 0,
|
||||||
signMethod: 'PAPER',
|
signMethod: 'PAPER',
|
||||||
attachmentUrl: '',
|
attachmentUrl: '',
|
||||||
attachments: [],
|
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.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">
|
<div className="md:col-span-2 border-t pt-3">
|
||||||
<Label>签订方式</Label>
|
<Label>签订方式</Label>
|
||||||
<Select value={form.signMethod} onChange={(e) => setForm({ ...form, signMethod: e.target.value as 'PAPER' | 'ELECTRONIC' })}>
|
<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.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.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.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.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.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>
|
<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[] }) {
|
export default function DisciplinaryInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [showForm, setShowForm] = useState(false)
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => rosterApi.createDisciplinary(employeeId, data),
|
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>)}
|
{Object.entries(actionMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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><Label>见证人</Label><Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} /></div>
|
||||||
<div className="flex items-center gap-2 pt-6">
|
<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 })} />
|
<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">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-gray-400">处理方式</span>
|
<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>
|
<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>}
|
{r.actionDetail && <span className="text-gray-500">{r.actionDetail}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs pt-1 border-t">
|
<div className="flex items-center gap-4 text-xs pt-1 border-t">
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function DisciplinaryRecords() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-4 text-gray-600">{ACTION_LABELS[r.action] || r.action}</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">
|
<td className="py-2 pr-4">
|
||||||
{r.employeeAck ? (
|
{r.employeeAck ? (
|
||||||
<span className="text-xs text-green-600">已签字</span>
|
<span className="text-xs text-green-600">已签字</span>
|
||||||
@@ -226,6 +226,7 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
|
|||||||
severity: record?.severity || 'WARNING',
|
severity: record?.severity || 'WARNING',
|
||||||
action: record?.action || 'ORAL_WARNING',
|
action: record?.action || 'ORAL_WARNING',
|
||||||
actionDetail: record?.actionDetail || '',
|
actionDetail: record?.actionDetail || '',
|
||||||
|
deductionAmount: record?.deductionAmount || 0,
|
||||||
witness: record?.witness || '',
|
witness: record?.witness || '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -289,8 +290,14 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>处罚执行细节</Label>
|
<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>
|
</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>
|
<div>
|
||||||
<Label>见证人</Label>
|
<Label>见证人</Label>
|
||||||
<Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" />
|
<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) => (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
{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 === '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 === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||||||
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
{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 todayStr = new Date().toISOString().slice(0, 10)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
newSalary: '',
|
newSalary: '',
|
||||||
|
newBaseSalary: '',
|
||||||
|
newPerformanceSalary: '',
|
||||||
effectiveDate: todayStr,
|
effectiveDate: todayStr,
|
||||||
reason: '',
|
reason: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
const base = parseFloat(form.newBaseSalary) || 0
|
||||||
|
const perf = parseFloat(form.newPerformanceSalary) || 0
|
||||||
onSubmit({
|
onSubmit({
|
||||||
newSalary: parseFloat(form.newSalary),
|
newSalary: base + perf,
|
||||||
|
baseSalary: base,
|
||||||
|
performanceSalary: perf,
|
||||||
effectiveDate: new Date(form.effectiveDate).toISOString(),
|
effectiveDate: new Date(form.effectiveDate).toISOString(),
|
||||||
reason: form.reason || undefined,
|
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 (
|
return (
|
||||||
<Modal open onClose={onClose} title={`调薪 - ${employee.name}`}>
|
<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 className="text-xs text-gray-600 py-1.5">{employee.name} - {employee.department}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>当前月薪</Label>
|
<Label>当前工资</Label>
|
||||||
<div className="text-xs text-gray-600 py-1.5">¥{fmt(employee.monthlySalary)}</div>
|
<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>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>新月薪 *</Label>
|
<Label>新基本工资 *</Label>
|
||||||
<Input type="number" value={form.newSalary} onChange={(e) => setForm({ ...form, newSalary: e.target.value })} placeholder="元" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label>生效日期 *</Label>
|
<Label>生效日期 *</Label>
|
||||||
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
|
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<Label>调薪原因(选填)</Label>
|
<Label>调薪原因(选填)</Label>
|
||||||
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" />
|
<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
|
error: any
|
||||||
}) {
|
}) {
|
||||||
const originalSalary = employee.monthlySalary ? Number(employee.monthlySalary) : null
|
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({
|
const [form, setForm] = useState({
|
||||||
confirmDate: new Date().toISOString().slice(0, 10),
|
confirmDate: new Date().toISOString().slice(0, 10),
|
||||||
regularSalary: originalSalary ? String(originalSalary) : '',
|
regularSalary: originalSalary ? String(originalSalary) : '',
|
||||||
|
regularBaseSalary: originalBaseSalary != null ? String(originalBaseSalary) : '',
|
||||||
|
regularPerformanceSalary: originalPerformanceSalary != null ? String(originalPerformanceSalary) : '0',
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 转正薪资与原薪资是否不同 */
|
/** 转正薪资与原薪资是否不同 */
|
||||||
const salaryChanged = form.regularSalary !== '' && Number(form.regularSalary) !== originalSalary
|
const salaryChanged = form.regularSalary !== '' && Number(form.regularSalary) !== originalSalary
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
const base = parseFloat(form.regularBaseSalary) || 0
|
||||||
|
const perf = parseFloat(form.regularPerformanceSalary) || 0
|
||||||
onSubmit({
|
onSubmit({
|
||||||
employeeId: employee.id,
|
employeeId: employee.id,
|
||||||
confirmDate: new Date(form.confirmDate).toISOString(),
|
confirmDate: new Date(form.confirmDate).toISOString(),
|
||||||
regularSalary: form.regularSalary ? Number(form.regularSalary) : undefined,
|
regularSalary: base + perf,
|
||||||
|
baseSalary: base,
|
||||||
|
performanceSalary: perf,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,9 +399,27 @@ export function ConfirmModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
<Label>转正日期 *</Label>
|
<Label>转正日期 *</Label>
|
||||||
<Input type="date" value={form.confirmDate} onChange={(e) => setForm({ ...form, confirmDate: e.target.value })} />
|
<Input type="date" value={form.confirmDate} onChange={(e) => setForm({ ...form, confirmDate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>转正薪资{originalSalary ? `(原薪资 ¥${originalSalary})` : ''}</Label>
|
<Label>转正基本工资 *</Label>
|
||||||
<Input type="number" value={form.regularSalary} onChange={(e) => setForm({ ...form, regularSalary: e.target.value })} placeholder={originalSalary ? `不填则保持原薪资 ¥${originalSalary}` : '请输入转正薪资'} />
|
<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">
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
{originalSalary
|
{originalSalary
|
||||||
? (salaryChanged
|
? (salaryChanged
|
||||||
@@ -376,7 +427,6 @@ export function ConfirmModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
: '填写后将更新员工月工资并记录薪资变更')
|
: '填写后将更新员工月工资并记录薪资变更')
|
||||||
: '填写后将更新员工月工资并记录薪资变更'}
|
: '填写后将更新员工月工资并记录薪资变更'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-danger">
|
<div className="text-xs text-danger">
|
||||||
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
||||||
@@ -438,6 +488,9 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
contractYears: 3,
|
contractYears: 3,
|
||||||
probationMonths: 0,
|
probationMonths: 0,
|
||||||
probationSalary: 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: '',
|
socialInsBase: '', socialInsStartMonth: '',
|
||||||
housingFundBase: '', housingFundStartMonth: '',
|
housingFundBase: '', housingFundStartMonth: '',
|
||||||
})
|
})
|
||||||
@@ -470,11 +523,14 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const monthlySalaryNum = employee?.monthlySalary || 0
|
const monthlySalaryNum = employee?.monthlySalary || 0
|
||||||
|
const baseSalaryNum = parseFloat(form.baseSalary) || 0
|
||||||
const probationSalaryError = (() => {
|
const probationSalaryError = (() => {
|
||||||
if (form.probationMonths <= 0) return ''
|
if (form.probationMonths <= 0) return ''
|
||||||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||||||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
// 试用期工资不得低于基本工资的80%
|
||||||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
const refSalary = baseSalaryNum > 0 ? baseSalaryNum : monthlySalaryNum
|
||||||
|
if (refSalary > 0 && form.probationSalary < refSalary * 0.8) {
|
||||||
|
return `试用期工资不得低于基本工资的80%(最低¥${(refSalary * 0.8).toFixed(0)})`
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})()
|
})()
|
||||||
@@ -518,9 +574,14 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
const base = parseFloat(form.baseSalary) || 0
|
||||||
|
const perf = parseFloat(form.performanceSalary) || 0
|
||||||
const data: any = {
|
const data: any = {
|
||||||
hireDate: new Date(form.hireDate).toISOString(),
|
hireDate: new Date(form.hireDate).toISOString(),
|
||||||
department: form.department,
|
department: form.department,
|
||||||
|
baseSalary: base,
|
||||||
|
performanceSalary: perf,
|
||||||
|
monthlySalary: base + perf,
|
||||||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||||||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||||||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||||||
@@ -535,6 +596,8 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
contractYears: form.contractYears,
|
contractYears: form.contractYears,
|
||||||
probationMonths: form.probationMonths,
|
probationMonths: form.probationMonths,
|
||||||
probationSalary: form.probationSalary,
|
probationSalary: form.probationSalary,
|
||||||
|
baseSalary: base,
|
||||||
|
performanceSalary: perf,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onSubmit(data)
|
onSubmit(data)
|
||||||
@@ -569,13 +632,35 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
<Label>新入职日期 *</Label>
|
<Label>新入职日期 *</Label>
|
||||||
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
|
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
|
||||||
</div>
|
</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">
|
<div className="border-t pt-3">
|
||||||
<Label>社保公积金</Label>
|
<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 className="grid grid-cols-4 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>社保缴费基数</Label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label>社保开始年月</Label>
|
<Label>社保开始年月</Label>
|
||||||
@@ -583,7 +668,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>公积金缴费基数</Label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label>公积金开始年月</Label>
|
<Label>公积金开始年月</Label>
|
||||||
@@ -641,8 +726,8 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
|||||||
<div>
|
<div>
|
||||||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
<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} />
|
<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 && (
|
{baseSalaryNum > 0 && form.probationMonths > 0 && (
|
||||||
<div className="text-xs text-gray-400 mt-0.5">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
<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>}
|
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -704,6 +789,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return {
|
return {
|
||||||
name: '', department: '', departmentId: '', position: '', hireDate: todayStr, monthlySalary: '',
|
name: '', department: '', departmentId: '', position: '', hireDate: todayStr, monthlySalary: '',
|
||||||
|
baseSalary: '', performanceSalary: '0',
|
||||||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||||||
city: '北京', education: '', status: 'ACTIVE',
|
city: '北京', education: '', status: 'ACTIVE',
|
||||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||||
@@ -900,11 +986,14 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
|
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
|
||||||
|
const baseSalaryNum = parseFloat(form.baseSalary) || 0
|
||||||
const probationSalaryError = (() => {
|
const probationSalaryError = (() => {
|
||||||
if (form.probationMonths <= 0) return ''
|
if (form.probationMonths <= 0) return ''
|
||||||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||||||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
// 试用期工资不得低于基本工资的80%
|
||||||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
const refSalary = baseSalaryNum > 0 ? baseSalaryNum : monthlySalaryNum
|
||||||
|
if (refSalary > 0 && form.probationSalary < refSalary * 0.8) {
|
||||||
|
return `试用期工资不得低于基本工资的80%(最低¥${(refSalary * 0.8).toFixed(0)})`
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})()
|
})()
|
||||||
@@ -944,7 +1033,10 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
name: form.name, department: form.department, departmentId: form.departmentId || undefined,
|
name: form.name, department: form.department, departmentId: form.departmentId || undefined,
|
||||||
position: form.position || undefined,
|
position: form.position || undefined,
|
||||||
hireDate: new Date(form.hireDate).toISOString(),
|
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,
|
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
|
||||||
idCardNumber: form.idCardNumber || undefined,
|
idCardNumber: form.idCardNumber || undefined,
|
||||||
phone: form.phone || undefined,
|
phone: form.phone || undefined,
|
||||||
@@ -971,7 +1063,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
onSubmit(data)
|
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
|
&& form.idCardNumber.length >= 18
|
||||||
&& ageWarning?.type !== 'BLOCK'
|
&& ageWarning?.type !== 'BLOCK'
|
||||||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||||||
@@ -979,7 +1071,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
// 超龄人员不得签订劳动合同
|
// 超龄人员不得签订劳动合同
|
||||||
&& (!isOverage || ['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(form.contractType))
|
&& (!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)
|
useUnsavedChanges(isDirty)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1022,7 +1114,27 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<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="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) => {
|
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => {
|
||||||
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
|
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
|
||||||
setForm({ ...form, phone })
|
setForm({ ...form, phone })
|
||||||
@@ -1051,7 +1163,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
{isNoSocialContract ? (
|
{isNoSocialContract ? (
|
||||||
<span className="text-xs text-amber-600">劳务协议/实习协议人员不缴纳社保公积金</span>
|
<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>
|
</div>
|
||||||
{/* 账户选择(选部门后自动带出,可手动调整) */}
|
{/* 账户选择(选部门后自动带出,可手动调整) */}
|
||||||
@@ -1193,8 +1305,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
<div>
|
<div>
|
||||||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
<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} />
|
<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 && (
|
{baseSalaryNum > 0 && form.probationMonths > 0 && (
|
||||||
<div className="text-xs text-gray-500 mt-1">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
<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>}
|
{probationSalaryError && <div className="text-xs text-danger mt-1">{probationSalaryError}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user