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:
selfrelease
2026-08-17 15:06:46 +08:00
parent c66ea290eb
commit 5196d7c80a
23 changed files with 812 additions and 74 deletions
+8 -1
View File
@@ -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?
+17
View File
@@ -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) => {
+332 -2
View File
@@ -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 {
+15 -6
View File
@@ -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,
}, },
}) })
+46
View File
@@ -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,
+2
View File
@@ -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,
+6 -6
View File
@@ -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[] = [
840 840
{{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}}
+6 -2
View File
@@ -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) {
+8
View File
@@ -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 },
], ],
+27 -1
View File
@@ -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
+21 -2
View File
@@ -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>
+26 -2
View File
@@ -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">
+4 -7
View File
@@ -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>
+17
View File
@@ -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>
)
}
+79 -1
View File
@@ -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 () => {
+23 -2
View File
@@ -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>
+8 -2
View File
@@ -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} />}
+139 -27
View File
@@ -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>