From 5196d7c80a258375a1d8dbc5e95d5e251ae34c32 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Mon, 17 Aug 2026 15:06:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20portal=E7=AB=AFtoken=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E5=90=8E=E8=87=AA=E5=8A=A8=E8=B7=B3=E8=BD=AC=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5=EF=BC=8C=E9=81=BF=E5=85=8D=E6=98=BE=E7=A4=BA=E7=A9=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- backend/prisma/schema.prisma | 9 +- backend/src/routes/attendance.routes.ts | 17 + backend/src/routes/payroll2.routes.ts | 334 +++++++++++++++++- backend/src/routes/roster.routes.ts | 21 +- backend/src/services/attendance.service.ts | 3 + backend/src/services/contract.service.ts | 46 +++ backend/src/services/payroll.service.ts | 2 + backend/src/services/template.service.ts | 12 +- backend/src/services/work-process.service.ts | 8 +- frontend/src/App.tsx | 8 + frontend/src/components/layout/SidebarNav.tsx | 3 +- frontend/src/lib/api-services.ts | 28 +- frontend/src/pages/Attendance.tsx | 23 +- frontend/src/pages/Contracts.tsx | 30 +- frontend/src/pages/Money.tsx | 11 +- frontend/src/pages/Overtime.tsx | 17 + frontend/src/pages/money/BatchTab.tsx | 80 ++++- frontend/src/pages/roster/BasicInfo.tsx | 25 +- frontend/src/pages/roster/ContractInfo.tsx | 10 +- .../src/pages/roster/DisciplinaryInfo.tsx | 8 +- .../src/pages/roster/DisciplinaryRecords.tsx | 11 +- frontend/src/pages/roster/EmployeeProfile.tsx | 2 +- frontend/src/pages/roster/modals.tsx | 178 ++++++++-- 23 files changed, 812 insertions(+), 74 deletions(-) create mode 100644 frontend/src/pages/Overtime.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 902058e..68f506b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -240,7 +240,9 @@ model Employee { // 组织架构扩展 departmentId String? // 关联 Department 表(兼容旧 department 字符串) supervisorId String? // 直属上级员工ID - monthlySalary String // AES-256 加密存储 + monthlySalary String // AES-256 加密存储(月薪总额 = 基本工资 + 绩效工资) + baseSalary String? // 基本工资(AES-256 加密,null=旧数据未拆分) + performanceSalary String? // 绩效工资(AES-256 加密,可为"0",null=旧数据未拆分) status EmployeeStatus @default(ACTIVE) gender String? phone String? @@ -348,6 +350,8 @@ model LaborContract { contractYears Int @default(3) probationMonths Int @default(0) probationSalary Int @default(0) + baseSalary Int @default(0) // 基本工资(转正后) + performanceSalary Int @default(0) // 绩效工资(转正后,可为0) renewalCount Int @default(0) attachmentName String? attachmentUrl String? @@ -713,6 +717,7 @@ model DisciplinaryRecord { severity String @default("WARNING") // WARNING/SERIOUS/SEVERE action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION actionDetail String? + deductionAmount Float @default(0) // 扣款金额(action=DEDUCTION 时填写,用于薪资批次导入) employeeAck Boolean @default(false) // 员工是否签字确认 ackDate DateTime? ackMethod String? // SIGN/ELECTRONIC/REFUSED @@ -820,6 +825,7 @@ model Payslip { month String // YYYY-MM // 薪酬构成 baseSalary Float @default(0) + performanceSalary Float @default(0) // 绩效工资 overtimePay Float @default(0) weekdayOvertimePay Float @default(0) weekendOvertimePay Float @default(0) @@ -1194,6 +1200,7 @@ model AttendanceConfirmation { weekendHours Float @default(0) holidayHours Float @default(0) overtimePay Float @default(0) + deductionAmount Float @default(0) // 考勤扣款金额(迟到/早退/旷工等,手动填写,用于薪资批次导入) confirmedAt DateTime? confirmedBy String? confirmIp String? diff --git a/backend/src/routes/attendance.routes.ts b/backend/src/routes/attendance.routes.ts index e99bd9b..49180cb 100644 --- a/backend/src/routes/attendance.routes.ts +++ b/backend/src/routes/attendance.routes.ts @@ -67,6 +67,7 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, ne weekendHours: z.number().min(0), holidayHours: z.number().min(0), overtimePay: z.number().min(0), + deductionAmount: z.number().min(0).optional(), })), }) const { month, items } = schema.parse(req.body) @@ -126,6 +127,22 @@ router.post('/batch-confirm', authMiddleware, async (req: AuthRequest, res: Resp } catch (err) { next(err) } }) +/** 更新考勤确认的扣款金额 */ +router.put('/confirmations/:id/deduction', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { deductionAmount } = req.body + const record = await prisma.attendanceConfirmation.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '考勤确认记录不存在' } }) + const updated = await prisma.attendanceConfirmation.update({ + where: { id: req.params.id }, + data: { deductionAmount: Number(deductionAmount) || 0 }, + }) + res.json({ success: true, data: updated }) + } catch (err) { next(err) } +}) + // ========== 班次管理 ========== router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index ca06c5f..00c8817 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -366,16 +366,28 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti const failedEmployees: { employeeId: string; name: string; error: string }[] = [] for (const emp of employees) { let baseSalary = 0 + let performanceSalary = 0 let overtimePay = 0 let allowance = 0 let deduction = 0 let bonus = 0 + // 解密员工工资结构 + let empBaseSalary = 0 + let empPerformanceSalary = 0 + if (emp.baseSalary) { + try { empBaseSalary = Number(decrypt(emp.baseSalary)) || 0 } catch { empBaseSalary = Number(emp.baseSalary) || 0 } + } + if (emp.performanceSalary) { + try { empPerformanceSalary = Number(decrypt(emp.performanceSalary)) || 0 } catch { empPerformanceSalary = Number(emp.performanceSalary) || 0 } + } + if (mode === 'copy_batch' && sourceEntries) { // 复制指定批次:从源条目复制数据 const srcEntry = sourceEntries.find(e => e.employeeId === emp.id) if (srcEntry) { baseSalary = srcEntry.baseSalary + performanceSalary = srcEntry.performanceSalary || 0 overtimePay = srcEntry.overtimePay allowance = srcEntry.allowance deduction = srcEntry.deduction @@ -397,17 +409,40 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti const inProbation = isInProbation(latestContract, batchMonthEnd) if (inProbation && latestContract.probationSalary > 0) { baseSalary = latestContract.probationSalary + performanceSalary = 0 // 试用期一般不发绩效 } else if (prevPayslip) { // 非试用期:优先用上月工资条的基本工资(保持薪资连续性) baseSalary = prevPayslip.baseSalary + performanceSalary = prevPayslip.performanceSalary || 0 + } else if (empBaseSalary > 0 || empPerformanceSalary > 0) { + // 新数据:有工资结构 + baseSalary = empBaseSalary + performanceSalary = empPerformanceSalary } else if (emp.monthlySalary) { + // 旧数据兼容:回退到 monthlySalary try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } } overtimePay = overtime?.totalPay || 0 allowance = prevPayslip?.allowance || 0 deduction = prevPayslip?.deduction || 0 + } else if (mode === 'custom' || mode === 'blank_employees') { + // 自定义/空白员工模式:取员工工资结构(非试用期) + const batchMonthEnd = new Date(`${month}-28T23:59:59`) + const latestContract = emp.contracts?.[0] + const inProbation = isInProbation(latestContract, batchMonthEnd) + if (inProbation && latestContract?.probationSalary > 0) { + baseSalary = latestContract.probationSalary + performanceSalary = 0 + } else if (empBaseSalary > 0 || empPerformanceSalary > 0) { + baseSalary = empBaseSalary + performanceSalary = empPerformanceSalary + } else if (emp.monthlySalary && mode === 'custom') { + // custom 模式旧数据兼容 + try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } + } + // blank_employees 模式:金额默认0,不自动带出 } - // blank_employees 和 blank_all: 所有金额默认 0(不自动带出基本工资,不取试用期工资) + // blank_all: 所有金额默认 0 // 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外) // blank_employees / blank_all 模式下所有金额应为 0,不覆盖试用期工资 @@ -416,6 +451,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti const latestContract = emp.contracts?.[0] if (isInProbation(latestContract, batchMonthEnd) && latestContract?.probationSalary > 0) { baseSalary = latestContract.probationSalary + performanceSalary = 0 } } @@ -442,7 +478,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti try { // 查询上月递延数据(上月已归档批次中该员工的递延金额) const prevDeferred = await getPrevDeferred(orgId, emp.id, month) - calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred }) + calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, performanceSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred }) } catch (calcErr: any) { // 单个员工计算失败不阻塞整个批次,记录错误并使用零值 failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' }) @@ -459,6 +495,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti orgId, employeeId: emp.id, baseSalary, + performanceSalary, overtimePay, allowance, deduction, @@ -692,6 +729,299 @@ router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Respo } }) +// 获取绩效工资:按批次月份从 PerformanceRecord 查考核等级,结合 Employee.performanceSalary 计算绩效工资 +// 绩效系数:A=1.2, B=1.0, C=0.8, D=0.6;无绩效记录则系数=1.0 +router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId } = req.params + const orgId = req.user!.orgId + + const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) + if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) + if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } }) + + // 获取批次所有条目 + const entries = await prisma.batchEntry.findMany({ + where: { batchId }, + select: { id: true, employeeId: true, performanceSalary: true }, + }) + + if (entries.length === 0) { + return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } }) + } + + // 查询批次月份的绩效记录(按 period = YYYY-MM 匹配) + const performanceRecords = await prisma.performanceRecord.findMany({ + where: { orgId, period: batch.month, periodType: 'MONTHLY' }, + select: { employeeId: true, grade: true, score: true, result: true }, + }) + const perfMap = new Map() + 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() + for (const emp of employees) { + if (emp.performanceSalary) { + try { empPerfSalaryMap.set(emp.id, Number(decrypt(emp.performanceSalary)) || 0) } catch {} + } + } + + // 绩效系数映射 + const gradeCoefficients: Record = { 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() + 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() + for (const c of confirmations) { + deductionMap.set(c.employeeId, c.deductionAmount) + } + + let filled = 0 + let totalAmount = 0 + for (const entry of entries) { + const deduction = deductionMap.get(entry.employeeId) + if (deduction && deduction > 0) { + await prisma.batchEntry.update({ + where: { id: entry.id }, + data: { deduction: Math.round(deduction * 100) / 100 }, + }) + filled++ + totalAmount += deduction + } + } + + // 重算批次汇总 + const allEntries = await prisma.batchEntry.findMany({ where: { batchId } }) + const totals = allEntries.reduce((acc, e) => ({ + totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0), + totalNetPay: acc.totalNetPay + e.netPay, + totalSocialOrg: acc.totalSocialOrg + e.socialOrg, + totalSocialEmp: acc.totalSocialEmp + e.socialEmp, + totalHousingOrg: acc.totalHousingOrg + e.housingOrg, + totalHousingEmp: acc.totalHousingEmp + e.housingEmp, + totalTax: acc.totalTax + e.tax, + }), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 }) + + await prisma.payrollBatch.update({ + where: { id: batchId }, + data: { + totalPay: Math.round(totals.totalPay * 100) / 100, + totalNetPay: Math.round(totals.totalNetPay * 100) / 100, + totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100, + totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100, + totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100, + totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100, + totalTax: Math.round(totals.totalTax * 100) / 100, + }, + }) + + res.json({ + success: true, + data: { + filled, + totalAmount: Math.round(totalAmount * 100) / 100, + message: filled > 0 + ? `已填充 ${filled} 人考勤扣款,合计 ¥${Math.round(totalAmount * 100) / 100}` + : `${batch.month} 无考勤扣款数据(需先在考勤确认中填写扣款金额)`, + }, + }) + } catch (err) { + next(err) + } +}) + // 获取条目个税计算明细 router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 04d2e29..3ac14fd 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -220,6 +220,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { idCardMasked, idCardNumber: safeDecryptStr(e.idCardNumber), monthlySalary: safeDecrypt(e.monthlySalary), + baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null, + performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null, socialInsBase: e.socialInsBase, housingFundBase: e.housingFundBase, socialInsCalc: (() => { @@ -373,7 +375,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) = if (found) monthlyProcessRecords.push(recordData) } - const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee + const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee const today = new Date() today.setHours(0, 0, 0, 0) const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE' @@ -383,6 +385,8 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) = ...rest, status: dynamicStatus, monthlySalary: safeDecrypt(monthlySalary), + baseSalary: baseSalary ? safeDecrypt(baseSalary) : null, + performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null, bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null, idCardNumber: safeDecryptStr(idCardNumber), monthlyProcessRecords, @@ -961,7 +965,7 @@ router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => { try { - const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body + const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body const record = await prisma.disciplinaryRecord.create({ data: { orgId: req.user!.orgId, @@ -972,6 +976,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest severity: severity || 'WARNING', action: action || 'ORAL_WARNING', actionDetail, + deductionAmount: Number(deductionAmount) || 0, employeeAck: employeeAck || false, ackDate: ackDate ? new Date(ackDate) : null, ackMethod, @@ -995,7 +1000,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { - const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body + const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body const record = await prisma.disciplinaryRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) @@ -1009,6 +1014,7 @@ router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: Au severity, action, actionDetail, + deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined, employeeAck, ackDate: ackDate ? new Date(ackDate) : null, ackMethod, @@ -1412,7 +1418,7 @@ function prevMonth(month: string): string { // 调薪 router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => { try { - const { newSalary, effectiveMonth, reason } = req.body + const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body const employee = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, }) @@ -1446,10 +1452,13 @@ router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, }, }) - // 同步 Employee 便捷字段 + // 同步 Employee 便捷字段(含工资结构) + const updateData: any = { monthlySalary: encrypt(String(newSalary)) } + if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0)) + if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0)) await prisma.employee.update({ where: { id: req.params.id }, - data: { monthlySalary: encrypt(String(newSalary)) }, + data: updateData, }) await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary }) diff --git a/backend/src/services/attendance.service.ts b/backend/src/services/attendance.service.ts index a6fb82d..0e9a288 100644 --- a/backend/src/services/attendance.service.ts +++ b/backend/src/services/attendance.service.ts @@ -48,6 +48,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId: weekendHours: number holidayHours: number overtimePay: number + deductionAmount?: number }>) { const results: Array<{ employeeId: string; success: boolean; error?: string }> = [] @@ -66,6 +67,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId: weekendHours: item.weekendHours, holidayHours: item.holidayHours, overtimePay: item.overtimePay, + deductionAmount: item.deductionAmount || 0, status: 'PENDING', }, }) @@ -80,6 +82,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId: weekendHours: item.weekendHours, holidayHours: item.holidayHours, overtimePay: item.overtimePay, + deductionAmount: item.deductionAmount || 0, createdBy: userId, }, }) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index bf5bae8..b8bf995 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -14,6 +14,15 @@ function dateToMonth(date: Date): string { return `${y}-${m}` } +/** 安全解密数字(工资字段) */ +function safeDecryptNum(encrypted: string): number { + try { + return Number(decrypt(encrypted)) || 0 + } catch { + return Number(encrypted) || 0 + } +} + async function clampSocialInsBase(orgId: string, base: number, city?: string, accountId?: string): Promise { // 优先按账户查年度标准 if (accountId) { @@ -271,6 +280,9 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS } catch { decryptedSalary = Number(emp.monthlySalary) || 0 } + // 解密工资结构 + const decryptedBaseSalary = emp.baseSalary ? safeDecryptNum(emp.baseSalary) : null + const decryptedPerformanceSalary = emp.performanceSalary ? safeDecryptNum(emp.performanceSalary) : null return { id: emp.id, @@ -279,6 +291,8 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS hireDate: emp.hireDate.toISOString().slice(0, 10), status: emp.status, monthlySalary: decryptedSalary, + baseSalary: decryptedBaseSalary, + performanceSalary: decryptedPerformanceSalary, contractStatus: contractInfo.status, contractStatusText: contractInfo.statusText, riskLevel: contractInfo.riskLevel, @@ -315,6 +329,8 @@ export async function getEmployeeDetail(orgId: string, id: string) { return { ...employee, monthlySalary: decryptedSalary, + baseSalary: employee.baseSalary ? safeDecryptNum(employee.baseSalary) : null, + performanceSalary: employee.performanceSalary ? safeDecryptNum(employee.performanceSalary) : null, } } @@ -368,6 +384,9 @@ export async function createEmployee(orgId: string, userId: string, data: any) { const hireDate = new Date(data.hireDate) const hireMonth = dateToMonth(hireDate) const salaryNum = Number(data.monthlySalary) || 0 + // 工资结构:基本工资 + 绩效工资 = 月薪总额 + const baseSalaryNum = Number(data.baseSalary) || 0 + const performanceSalaryNum = Number(data.performanceSalary) || 0 const city = data.city || '北京' // 劳务协议/实习协议:不缴纳社保公积金,基数强制 0 const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType) @@ -389,6 +408,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) { department: data.department, hireDate, monthlySalary: encrypt(data.monthlySalary), + baseSalary: encrypt(String(baseSalaryNum)), + performanceSalary: encrypt(String(performanceSalaryNum)), gender: data.gender, femaleWorkerType: data.femaleWorkerType, phone: data.phone, @@ -488,6 +509,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) { contractYears: data.contract.contractYears || 3, probationMonths: data.contract.probationMonths || 0, probationSalary: data.contract.probationSalary || 0, + baseSalary: baseSalaryNum, + performanceSalary: performanceSalaryNum, createdBy: userId, }, }) @@ -526,6 +549,10 @@ export async function rehireEmployee(orgId: string, userId: string, id: string, const newHireMonth = dateToMonth(newHireDate) const salaryNum = Number(decrypt(employee.monthlySalary)) || 0 + // 重新入职时支持更新工资结构 + const baseSalaryNum = data.baseSalary != null ? Number(data.baseSalary) : (employee.baseSalary ? Number(decrypt(employee.baseSalary)) || 0 : 0) + const performanceSalaryNum = data.performanceSalary != null ? Number(data.performanceSalary) : (employee.performanceSalary ? Number(decrypt(employee.performanceSalary)) || 0 : 0) + const newMonthlySalary = data.baseSalary != null ? baseSalaryNum + performanceSalaryNum : salaryNum const city = data.city || employee.city || '北京' const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum @@ -575,6 +602,12 @@ export async function rehireEmployee(orgId: string, userId: string, id: string, housingFundStartMonth, housingFundEndMonth: null, city: data.city || employee.city || '北京', + // 更新工资结构 + ...(data.baseSalary != null ? { + monthlySalary: encrypt(String(newMonthlySalary)), + baseSalary: encrypt(String(baseSalaryNum)), + performanceSalary: encrypt(String(performanceSalaryNum)), + } : {}), }, }) @@ -657,6 +690,8 @@ export async function rehireEmployee(orgId: string, userId: string, id: string, contractYears: data.contract.contractYears || 3, probationMonths: data.contract.probationMonths || 0, probationSalary: data.contract.probationSalary || 0, + baseSalary: baseSalaryNum, + performanceSalary: performanceSalaryNum, createdBy: userId, }, }) @@ -702,6 +737,13 @@ export async function updateEmployee(orgId: string, id: string, data: any) { const oldSalary = Number(decrypt(employee.monthlySalary)) || 0 const newSalary = Number(data.monthlySalary) || 0 updateData.monthlySalary = encrypt(data.monthlySalary) + // 同步更新工资结构 + if (data.baseSalary !== undefined) { + updateData.baseSalary = encrypt(String(Number(data.baseSalary) || 0)) + } + if (data.performanceSalary !== undefined) { + updateData.performanceSalary = encrypt(String(Number(data.performanceSalary) || 0)) + } // 记录薪资变更 if (oldSalary !== newSalary) { const now = new Date() @@ -912,6 +954,8 @@ export async function batchRenew(orgId: string, userId: string, contractIds: str contractYears: years, probationMonths: 0, probationSalary: 0, + baseSalary: contract.baseSalary || 0, + performanceSalary: contract.performanceSalary || 0, renewalCount: contract.renewalCount + 1, createdBy: userId, }, @@ -963,6 +1007,8 @@ export async function addContract(orgId: string, userId: string, data: any) { contractYears: data.contractYears || 3, probationMonths: data.probationMonths || 0, probationSalary: data.probationSalary || 0, + baseSalary: data.baseSalary || 0, + performanceSalary: data.performanceSalary || 0, attachmentName: data.attachmentUrl ? '合同扫描件' : null, attachmentUrl: data.attachmentUrl || null, electronicContractNo: data.electronicContractNo || null, diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index e826d7d..fb3a91b 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -638,6 +638,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) { where: { employeeId_month: { employeeId, month } }, update: { baseSalary: Math.round(summary.baseSalary * 100) / 100, + performanceSalary: Math.round(summary.performanceSalary * 100) / 100, overtimePay: Math.round(summary.overtimePay * 100) / 100, allowance: Math.round(summary.allowance * 100) / 100, deduction: Math.round(summary.deduction * 100) / 100, @@ -659,6 +660,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) { employeeId, month, baseSalary: Math.round(summary.baseSalary * 100) / 100, + performanceSalary: Math.round(summary.performanceSalary * 100) / 100, overtimePay: Math.round(summary.overtimePay * 100) / 100, allowance: Math.round(summary.allowance * 100) / 100, deduction: Math.round(summary.deduction * 100) / 100, diff --git a/backend/src/services/template.service.ts b/backend/src/services/template.service.ts index 907060a..648e9e5 100644 --- a/backend/src/services/template.service.ts +++ b/backend/src/services/template.service.ts @@ -18,7 +18,7 @@ export const documentTemplates: DocumentTemplate[] = [ name: '固定期限劳动合同', category: 'CONTRACT', description: '标准固定期限劳动合同模板,适用于大多数正式员工', - variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'], + variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'baseSalary', 'performanceSalary', 'probationSalary', 'socialInsBase'], content: `劳动合同书 甲方(用人单位):{{companyName}} @@ -36,7 +36,7 @@ export const documentTemplates: DocumentTemplate[] = [ 甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。 第四条 劳动报酬 -乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。 +乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。试用期月工资为人民币{{probationSalary}}元。甲方于每月15日前支付上月工资。 第五条 社会保险和福利 甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。 @@ -64,7 +64,7 @@ export const documentTemplates: DocumentTemplate[] = [ name: '无固定期限劳动合同', category: 'CONTRACT', description: '无固定期限劳动合同模板,适用于符合签订条件的情况', - variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'], + variables: ['companyName', 'employeeName', 'startDate', 'position', 'baseSalary', 'performanceSalary'], content: `无固定期限劳动合同书 甲方(用人单位):{{companyName}} @@ -79,7 +79,7 @@ export const documentTemplates: DocumentTemplate[] = [ 乙方担任{{position}}岗位。 第三条 劳动报酬 -乙方月工资为人民币{{monthlySalary}}元。 +乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。 (其余条款参照固定期限劳动合同模板) @@ -241,7 +241,7 @@ ____年__月__日 name: '试用期转正通知书', category: 'NOTICE', description: '试用期考核合格转正通知', - variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'], + variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'baseSalary', 'performanceSalary', 'companyName'], content: `试用期转正通知书 {{employeeName}}: @@ -249,7 +249,7 @@ ____年__月__日 经考核,你在试用期内表现合格,符合岗位要求。现通知你: 一、自{{regularDate}}起正式转正,担任{{position}}岗位。 -二、转正后月工资为人民币{{monthlySalary}}元。 +二、转正后基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月。 三、试用期至{{probationEndDate}}结束。 请继续遵守公司各项规章制度,努力工作。 diff --git a/backend/src/services/work-process.service.ts b/backend/src/services/work-process.service.ts index ca511cf..172b6a7 100644 --- a/backend/src/services/work-process.service.ts +++ b/backend/src/services/work-process.service.ts @@ -66,15 +66,19 @@ export async function executeWorkProcess(processId: string, type: string, formDa return { employeeId } } case 'CONFIRM': { - const { employeeId, regularSalary } = formData + const { employeeId, regularSalary, baseSalary, performanceSalary } = formData if (employeeId) { if (regularSalary) { const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0 const newSalary = Number(regularSalary) || 0 + // 同步更新工资结构 + const updateData: any = { monthlySalary: encrypt(String(regularSalary)) } + if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0)) + if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0)) await prisma.employee.update({ where: { id: employeeId }, - data: { monthlySalary: encrypt(String(regularSalary)) }, + data: updateData, }) // 记录薪资变更(试用期薪资 → 转正薪资) if (oldSalary !== newSalary) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0977d5..7f939f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ const ForgotPassword = lazyRetry(() => import('./pages/auth/ForgotPassword')) const Dashboard = lazyRetry(() => import('./pages/Dashboard')) const Money = lazyRetry(() => import('./pages/Money')) const SocialInsurance = lazyRetry(() => import('./pages/SocialInsurance')) +const Overtime = lazyRetry(() => import('./pages/Overtime')) const Roster = lazyRetry(() => import('./pages/Roster')) const OrgChart = lazyRetry(() => import('./pages/OrgChart')) const SupportDashboard = lazyRetry(() => import('./pages/support/SupportDashboard')) @@ -141,6 +142,12 @@ function PlatformLayout({ children }: { children: React.ReactNode }) { } function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) { + // 检查 portal token 是否存在,不存在则跳转登录页 + const portalToken = localStorage.getItem('portalToken') + if (!portalToken) { + window.location.href = '/portal/login' + return null + } if (!showNav) { return (
@@ -192,6 +199,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 9415eb0..d2e60e4 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -16,7 +16,7 @@ import { ChevronDown, ChevronRight, Building2, CalendarDays, ClipboardList, Heart, CalendarClock, Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle, - DollarSign, + DollarSign, Clock, } from 'lucide-react' import { settingsApi } from '../../lib/api-services' @@ -64,6 +64,7 @@ const navGroups: NavGroup[] = [ items: [ { path: '/money', label: '薪税管理', icon: Calculator }, { path: '/social', label: '社保公积金', icon: Shield }, + { path: '/overtime', label: '加班费计算', icon: Clock }, { path: '/commission-bonus', label: '提成奖金', icon: DollarSign }, { path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 }, ], diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 26c9e20..b8e8b8e 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -294,6 +294,12 @@ export const attendanceApi = { /** 批量确认 */ batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) => post('/attendance/batch-confirm', data).then(unwrap()), + /** 批量创建/更新考勤确认记录 */ + batchCreate: (data: { month: string; items: any[] }) => + post('/attendance/batch', data).then(unwrap()), + /** 更新考勤确认扣款金额 */ + updateDeduction: (id: string, deductionAmount: number) => + put(`/attendance/confirmations/${id}/deduction`, { deductionAmount }).then(unwrap()), /** 单条确认 */ confirm: (data: { employeeId: string; month: string }) => post('/attendance/confirm', data).then(unwrap()), @@ -480,6 +486,15 @@ export const payrollApi = { /** 获取提成奖金到批次 */ fetchBonusToBatch: (batchId: string) => post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap()), + /** 获取绩效工资到批次(按考核系数计算) */ + fetchPerformanceToBatch: (batchId: string) => + post(`/payroll2/batches/${batchId}/fetch-performance`).then(unwrap()), + /** 获取违纪扣款到批次 */ + fetchDisciplinaryToBatch: (batchId: string) => + post(`/payroll2/batches/${batchId}/fetch-disciplinary`).then(unwrap()), + /** 获取考勤扣款到批次 */ + fetchAttendanceDeductionToBatch: (batchId: string) => + post(`/payroll2/batches/${batchId}/fetch-attendance-deduction`).then(unwrap()), /** 加班费配置 */ overtimeConfig: () => get('/payroll/overtime/config').then(unwrap()), @@ -1096,9 +1111,20 @@ portalAxios.interceptors.request.use((config: any) => { return config }) // 与管理端 api 实例一致:response interceptor 返回 response.data(后端 JSON body) +// 401 时清除 token 并跳转登录页(避免 token 过期后显示空数据) portalAxios.interceptors.response.use( (response) => response.data, - (error) => Promise.reject(error), + (error) => { + if (error?.response?.status === 401) { + localStorage.removeItem('portalToken') + localStorage.removeItem('portalEmployee') + // 避免在登录页重复跳转 + if (!window.location.pathname.includes('/portal/login') && !window.location.pathname.includes('/portal/auto-login')) { + window.location.href = '/portal/login' + } + } + return Promise.reject(error) + }, ) const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx index 97cf443..9a3a273 100644 --- a/frontend/src/pages/Attendance.tsx +++ b/frontend/src/pages/Attendance.tsx @@ -110,7 +110,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) { const [selectedIds, setSelectedIds] = useState>(new Set()) const [searchQuery, setSearchQuery] = useState('') const [editItem, setEditItem] = useState(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(null) const pageSize = usePageSize() const [page, setPage] = useState(1) @@ -193,7 +193,19 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) { const editMutation = useMutation({ mutationFn: async (data: any) => { - return await attendanceApi.manualCorrect(data) + // 通过批量创建接口更新考勤确认记录(upsert 语义) + return await attendanceApi.batchCreate({ + month: data.month, + items: [{ + employeeId: data.employeeId, + workDays: data.workDays, + weekdayHours: 0, + weekendHours: 0, + holidayHours: 0, + overtimePay: data.overtimePay, + deductionAmount: data.deductionAmount || 0, + }], + }) }, onSuccess: () => { toast.success('考勤记录已修改') @@ -421,6 +433,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) { 周末加班 {item.weekendHours}h 法定加班 {item.holidayHours}h 加班费 ¥{item.overtimePay?.toFixed(2)} + {item.deductionAmount > 0 && 扣款 ¥{item.deductionAmount?.toFixed(2)}}
{item.disputeNote && (
异议说明:{item.disputeNote}
@@ -449,6 +462,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) { leaveDays: item.leaveDays || 0, overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0), overtimePay: item.overtimePay || 0, + deductionAmount: item.deductionAmount || 0, }) }} > @@ -510,6 +524,11 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) { setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} /> +
+ + setEditForm({ ...editForm, deductionAmount: Number(e.target.value) })} placeholder="迟到/早退/旷工等扣款金额" /> +
diff --git a/frontend/src/pages/Contracts.tsx b/frontend/src/pages/Contracts.tsx index 7c8af2c..ae97414 100644 --- a/frontend/src/pages/Contracts.tsx +++ b/frontend/src/pages/Contracts.tsx @@ -240,6 +240,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: { position: '', hireDate: '', monthlySalary: '', + baseSalary: '', + performanceSalary: '0', gender: '男' as '男' | '女', phone: '', isPregnant: false, @@ -261,6 +263,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: { position: form.position || undefined, hireDate: new Date(form.hireDate).toISOString(), monthlySalary: form.monthlySalary, + baseSalary: form.baseSalary, + performanceSalary: form.performanceSalary || '0', gender: form.gender, phone: form.phone || undefined, isPregnant: form.isPregnant, @@ -276,6 +280,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: { contractYears: form.contractYears, probationMonths: form.probationMonths, probationSalary: form.probationSalary, + baseSalary: parseFloat(form.baseSalary) || 0, + performanceSalary: parseFloat(form.performanceSalary) || 0, } } onSubmit(data) @@ -317,9 +323,27 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
-
- - setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /> +
+
+ + { + 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="元" /> +
+
+ + { + 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" /> +
+
+ +
¥{(parseFloat(form.baseSalary) || 0) + (parseFloat(form.performanceSalary) || 0)}
+
diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index 0d5566e..d43d856 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -1,15 +1,14 @@ import { useState, Suspense } from 'react' import { useSearchParams } from 'react-router-dom' -import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react' +import { Layers, Wallet, LayoutTemplate, Receipt, Loader2 } from 'lucide-react' import PageGuide from '../components/ui/PageGuide' import { lazyRetry } from '../lib/lazyRetry' const BatchManager = lazyRetry(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager }))) const TemplateManager = lazyRetry(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager }))) -const OvertimeCalculator = lazyRetry(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator }))) const PayslipManager = lazyRetry(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager }))) -type Tab = 'batch' | 'template' | 'overtime' | 'payslip' +type Tab = 'batch' | 'template' | 'payslip' export default function Money() { const [searchParams] = useSearchParams() @@ -20,18 +19,17 @@ export default function Money() { const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ { key: 'batch', label: '发薪批次', icon: }, { key: 'template', label: '薪酬模版', icon: }, - { key: 'overtime', label: '加班费计算', icon: }, { key: 'payslip', label: '工资条管理', icon: }, ] return (
- 薪酬管理,支持工资条发放、批量算薪、加班费计算和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理。 + 薪酬管理,支持工资条发放、批量算薪和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理,加班费在「加班费计算」模块单独计算后导入。

薪税管理

-

统一管理发薪批次、工资条及加班薪酬

+

统一管理发薪批次、工资条及薪酬模板

@@ -53,7 +51,6 @@ export default function Money() {
}> {tab === 'batch' && } {tab === 'template' && } - {tab === 'overtime' && } {tab === 'payslip' && }
diff --git a/frontend/src/pages/Overtime.tsx b/frontend/src/pages/Overtime.tsx new file mode 100644 index 0000000..0fd67f4 --- /dev/null +++ b/frontend/src/pages/Overtime.tsx @@ -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 ( +
}> + + + ) +} diff --git a/frontend/src/pages/money/BatchTab.tsx b/frontend/src/pages/money/BatchTab.tsx index 72391e2..84a721e 100644 --- a/frontend/src/pages/money/BatchTab.tsx +++ b/frontend/src/pages/money/BatchTab.tsx @@ -3,7 +3,7 @@ import { usePageSize } from '../../hooks/usePageSize' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../../hooks/useConfirm' -import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck, DollarSign } from 'lucide-react' +import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck, DollarSign, CalendarCheck } from 'lucide-react' import { Stepper, type Step } from '../../components/ui/Stepper' import { InlineAlert } from '../../components/ui/InlineAlert' import PageGuide from '../../components/ui/PageGuide' @@ -597,6 +597,54 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }, }) + const fetchPerformanceMutation = useMutation({ + mutationFn: () => payrollApi.fetchPerformanceToBatch(batchId), + onSuccess: (res: any) => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) + if (res.data?.filled > 0) { + toast.success(res.data.message) + } else { + toast.info(res.data?.message || '无绩效工资数据') + } + }, + onError: () => { + toast.error('获取绩效工资失败') + }, + }) + + const fetchDisciplinaryMutation = useMutation({ + mutationFn: () => payrollApi.fetchDisciplinaryToBatch(batchId), + onSuccess: (res: any) => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) + if (res.data?.filled > 0) { + toast.success(res.data.message) + } else { + toast.info(res.data?.message || '无违纪扣款数据') + } + }, + onError: () => { + toast.error('获取违纪扣款失败') + }, + }) + + const fetchAttendanceDeductionMutation = useMutation({ + mutationFn: () => payrollApi.fetchAttendanceDeductionToBatch(batchId), + onSuccess: (res: any) => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) + if (res.data?.filled > 0) { + toast.success(res.data.message) + } else { + toast.info(res.data?.message || '无考勤扣款数据') + } + }, + onError: () => { + toast.error('获取考勤扣款失败') + }, + }) + const importPayrollMutation = useMutation({ mutationFn: async (file: File) => { const token = useAuthStore.getState().accessToken @@ -825,6 +873,36 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void {fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'} + + +