From 2a09d31ccc4a4dd96651c4b6426bbcaeff993277 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 23 Jul 2026 13:42:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=86=E9=A1=B5=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E3=80=81Dashboard=E5=BE=85=E5=8A=9E=E5=9B=BE=E6=A0=87=E3=80=81?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E4=B8=8E=E5=B7=A5=E8=B5=84=E6=9D=A1=E8=A7=A3?= =?UTF-8?q?=E8=80=A6=E3=80=81=E6=80=BB=E8=A7=88=E6=95=B0=E6=8D=AE=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页 - Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度) - 待办分为「风险提醒」「月度任务」两个顶层 tab - 归档与工资条生成解耦:归档只锁定批次,工资条单独生成 - 工资条管理新增「从批次汇总生成」按钮 - Dashboard 总览优先从已归档批次 BatchEntry 汇总数据 - 新增工资条生成待办提醒,生成后自动标记完成 - 修复高风险统计只含 CONTRACT/TERMINATION 类型 - 修复月度任务去重逻辑覆盖 SALARY 类型 --- backend/prisma/schema.prisma | 142 +++- backend/prisma/seed.ts | 297 ++++++-- backend/src/app.ts | 2 + backend/src/routes/payroll2.routes.ts | 555 ++++++++++++++ backend/src/routes/settings.routes.ts | 9 +- backend/src/schemas/contract.schema.ts | 3 + backend/src/services/contract.service.ts | 23 +- backend/src/services/payroll.service.ts | 324 ++++++++ backend/src/services/risk.service.ts | 118 ++- frontend/src/components/ui/Pagination.tsx | 95 +++ frontend/src/pages/Compensation.tsx | 21 +- frontend/src/pages/Dashboard.tsx | 59 +- frontend/src/pages/Money.tsx | 700 +++++++++++++++--- frontend/src/pages/Roster.tsx | 106 ++- frontend/src/pages/Settings.tsx | 11 + frontend/src/pages/Termination.tsx | 45 +- frontend/src/pages/portal/ContractConfirm.tsx | 5 +- frontend/src/pages/portal/MyContract.tsx | 5 +- frontend/src/pages/portal/Payslip.tsx | 13 +- frontend/src/types/index.ts | 2 + 20 files changed, 2300 insertions(+), 235 deletions(-) create mode 100644 backend/src/routes/payroll2.routes.ts create mode 100644 backend/src/services/payroll.service.ts create mode 100644 frontend/src/components/ui/Pagination.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4ca0949..34a0e1c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -50,6 +50,27 @@ enum RiskLevel { LOW } +enum PayrollBatchType { + REGULAR // 常规发薪 + TERMINATION // 离职结算 + BONUS // 年终奖/奖金 +} + +enum PayrollBatchStatus { + DRAFT // 草稿(可编辑) + ARCHIVED // 归档(已发薪,锁定) +} + +enum PayslipItemType { + INPUT // 手工输入项(计算依据) + CALCULATED // 计算项(公式自动计算) +} + +enum PayslipStatus { + PENDING // 待发布 + PUBLISHED // 已发布到员工端 +} + enum RiskStatus { PENDING RESOLVED @@ -90,6 +111,7 @@ model Organization { plan Plan @default(FREE) maxEmployees Int @default(20) city String? + payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -101,6 +123,9 @@ model Organization { riskItems RiskItem[] auditLogs AuditLog[] payslips Payslip[] + payrollBatches PayrollBatch[] + payslipItems PayslipItem[] + salaryChangeRecords SalaryChangeRecord[] onboardingLinks OnboardingLink[] confirmLinks ContractConfirmLink[] socialInsuranceConfig SocialInsuranceConfig? @@ -149,6 +174,10 @@ model Employee { isPregnant Boolean @default(false) isInMedicalPeriod Boolean @default(false) isWorkInjured Boolean @default(false) + // 薪税扩展 + socialInsBase Float? // 社保缴费基数(按人核定) + housingFundBase Float? // 公积金缴费基数(按人核定) + specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报) createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -158,6 +187,8 @@ model Employee { terminations TerminationRecord[] riskItems RiskItem[] payslips Payslip[] + salaryChanges SalaryChangeRecord[] + batchEntries BatchEntry[] attachments EmployeeAttachment[] disciplinaryRecords DisciplinaryRecord[] attendanceRecords AttendanceRecord[] @@ -436,6 +467,7 @@ model Payslip { employeeId String employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) month String // YYYY-MM + // 薪酬构成 baseSalary Float @default(0) overtimePay Float @default(0) weekdayOvertimePay Float @default(0) @@ -443,14 +475,122 @@ model Payslip { holidayOvertimePay Float @default(0) allowance Float @default(0) deduction Float @default(0) - totalPay Float @default(0) + bonus Float @default(0) // 奖金/年终奖 + totalPay Float @default(0) // 应发合计 + // 扣除项 + socialEmp Float @default(0) // 个人社保 + housingEmp Float @default(0) // 个人公积金 + tax Float @default(0) // 个人所得税 + netPay Float @default(0) // 实发工资 = totalPay - socialEmp - housingEmp - tax + // 累计预扣法 + ytdIncome Float @default(0) // 当年累计收入 + ytdTaxDeducted Float @default(0) // 当年累计已扣税 + ytdSocialEmp Float @default(0) // 当年累计个人社保 + ytdHousingEmp Float @default(0) // 当年累计个人公积金 + // 状态 + status PayslipStatus @default(PENDING) // PENDING → PUBLISHED confirmedAt DateTime? confirmedIp String? + publishedAt DateTime? // 工资条发布到员工端的时间 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([employeeId, month]) @@index([orgId, month]) + @@index([orgId, status]) +} + +model PayrollBatch { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + month String // YYYY-MM + batchNo Int // 批次序号(1, 2, 3...) + name String // 批次名称 + type PayrollBatchType @default(REGULAR) + status PayrollBatchStatus @default(DRAFT) + employeeCount Int @default(0) + totalPay Float @default(0) + totalNetPay Float @default(0) + totalSocialOrg Float @default(0) + totalSocialEmp Float @default(0) + totalHousingOrg Float @default(0) + totalHousingEmp Float @default(0) + totalTax Float @default(0) + remark String? + createdBy String + createdAt DateTime @default(now()) + archivedAt DateTime? + updatedAt DateTime @updatedAt + + entries BatchEntry[] + + @@unique([orgId, month, batchNo]) + @@index([orgId, month]) + @@index([orgId, status]) +} + +model BatchEntry { + id String @id @default(cuid()) + batchId String + batch PayrollBatch @relation(fields: [batchId], references: [id], onDelete: Cascade) + orgId String + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + // 薪酬项(可编辑的输入项) + baseSalary Float @default(0) + overtimePay Float @default(0) + allowance Float @default(0) + deduction Float @default(0) + bonus Float @default(0) + // 自动计算项 + socialEmp Float @default(0) + socialOrg Float @default(0) + housingEmp Float @default(0) + housingOrg Float @default(0) + tax Float @default(0) + totalPay Float @default(0) // 应发合计 + netPay Float @default(0) // 实发工资 + // 风险提示 + riskWarnings Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([batchId, employeeId]) + @@index([orgId, employeeId]) +} + +model PayslipItem { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + name String // 显示名称 + code String // 字段代码 + type PayslipItemType @default(INPUT) + formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction" + order Int @default(0) + isDefault Boolean @default(true) // 系统预置项不可删除 + isEditable Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([orgId, code]) +} + +model SalaryChangeRecord { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + oldSalary Float + newSalary Float + effectiveDate DateTime // 生效日期 + reason String? + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, employeeId]) } model OnboardingLink { diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 1557a96..e13805d 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,29 +1,92 @@ import { PrismaClient } from '@prisma/client' import bcrypt from 'bcryptjs' -import { randomBytes } from 'crypto' +import { encrypt } from '../src/lib/crypto' const prisma = new PrismaClient() -async function main() { - // 创建测试企业 - let org = await prisma.organization.findFirst({ where: { name: '测试科技有限公司' } }) - if (!org) { - org = await prisma.organization.create({ - data: { - name: '测试科技有限公司', - plan: 'FREE', - maxEmployees: 20, - city: '上海', - }, - }) - } +// 社保计算(与 payroll.service.ts 一致) +function calcSocial(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100 + const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100 + return { socialEmp: Math.round(socialEmp * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100 } +} +function calcHousing(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const housingEmp = actualBase * config.housingEmp / 100 + const housingOrg = actualBase * config.housingOrg / 100 + return { housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100 } +} +function calcTax(taxableIncome: number): number { + if (taxableIncome <= 0) return 0 + let tax = 0 + if (taxableIncome <= 36000) tax = taxableIncome * 0.03 + else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520 + else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920 + else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920 + else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920 + else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920 + else tax = taxableIncome * 0.45 - 181920 + return Math.max(0, Math.round(tax * 100) / 100) +} - // 创建管理员用户 +// 9名员工完整数据 +const EMPLOYEES = [ + { name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' }, + { name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true }, + { name: '王强', gender: '男', dept: '销售部', phone: '13900000003', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' }, + { name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' }, + { name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' }, + { name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' }, + { name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' }, + { name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' }, + { name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' }, +] + +async function main() { + // 1. 清空所有数据(按依赖顺序删除) + console.log('清空现有数据...') + await prisma.notificationLog.deleteMany() + await prisma.auditLog.deleteMany() + await prisma.batchEntry.deleteMany() + await prisma.payrollBatch.deleteMany() + await prisma.payslipItem.deleteMany() + await prisma.salaryChangeRecord.deleteMany() + await prisma.payslip.deleteMany() + await prisma.overtimeRecord.deleteMany() + await prisma.terminationRecord.deleteMany() + await prisma.riskItem.deleteMany() + await prisma.employeeAttachment.deleteMany() + await prisma.disciplinaryRecord.deleteMany() + await prisma.attendanceRecord.deleteMany() + await prisma.trainingRecord.deleteMany() + await prisma.performanceRecord.deleteMany() + await prisma.laborContract.deleteMany() + await prisma.contractConfirmLink.deleteMany() + await prisma.onboardingLink.deleteMany() + await prisma.employee.deleteMany() + await prisma.socialInsuranceConfig.deleteMany() + await prisma.notificationSetting.deleteMany() + await prisma.user.deleteMany() + await prisma.organization.deleteMany() + console.log('数据已清空') + + // 2. 创建企业 + const org = await prisma.organization.create({ + data: { + name: '智云科技有限公司', + plan: 'PRO', + maxEmployees: 50, + city: '上海', + payrollFrequency: 1, + }, + }) + console.log('企业已创建:', org.name) + + // 3. 创建管理员 const passwordHash = await bcrypt.hash('12345678', 10) - const admin = await prisma.user.upsert({ - where: { phone: '13800000001' }, - update: {}, - create: { + const admin = await prisma.user.create({ + data: { orgId: org.id, phone: '13800000001', name: '管理员', @@ -31,40 +94,182 @@ async function main() { role: 'ADMIN', }, }) + console.log('管理员已创建:', admin.phone) - // 创建测试员工 - const salaryHash = randomBytes(32).toString('hex') - const employee = await prisma.employee.create({ + // 4. 创建社保配置(上海标准) + await prisma.socialInsuranceConfig.create({ data: { orgId: org.id, - name: '张三', - department: '技术部', - hireDate: new Date('2026-01-15'), - monthlySalary: 'encrypted:' + salaryHash, - phone: '13900000001', - gender: '男', - createdBy: admin.id, + city: '上海', + pensionOrg: 16, + pensionEmp: 8, + medicalOrg: 9.8, + medicalEmp: 2, + unemploymentOrg: 0.5, + unemploymentEmp: 0.5, + injuryOrg: 0.2, + maternityOrg: 0.8, + housingOrg: 7, + housingEmp: 7, + baseMin: 7384, + baseMax: 36921, + }, + }) + console.log('社保配置已创建') + + // 5. 创建通知设置 + await prisma.notificationSetting.create({ + data: { + orgId: org.id, + contractExpiry: true, + expiryDays: 30, + contractUnsigned: true, + overtimeAlert: true, + payslipReady: true, + payrollDay: 10, + socialInsDay: 15, + housingFundDay: 15, + taxDay: 15, }, }) - // 创建测试合同 - await prisma.laborContract.create({ - data: { - orgId: org.id, - employeeId: employee.id, - signDate: new Date('2026-01-20'), - startDate: new Date('2026-02-01'), - endDate: new Date('2029-01-31'), - contractType: 'FIXED', - signMethod: 'PAPER', - contractYears: 3, - probationMonths: 2, - probationSalary: 6400, - createdBy: admin.id, - }, - }) + // 6. 创建薪酬模版(预置项) + const defaultItems: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [ + { name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true }, + { name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false }, + { name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true }, + { name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true }, + { name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true }, + { name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false }, + { name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false }, + { name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false }, + { name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false }, + { name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false }, + ] + for (const item of defaultItems) { + await prisma.payslipItem.create({ + data: { orgId: org.id, ...item }, + }) + } + console.log('薪酬模版已创建') - console.log('Seed data created:', { org: org.id, admin: admin.id, employee: employee.id }) + // 7. 创建9名员工 + 合同 + for (let i = 0; i < EMPLOYEES.length; i++) { + const e = EMPLOYEES[i] + const emp = await prisma.employee.create({ + data: { + orgId: org.id, + name: e.name, + department: e.dept, + hireDate: new Date(e.hireDate), + monthlySalary: encrypt(String(e.salary)), + phone: e.phone, + gender: e.gender, + socialInsBase: e.socialBase, + housingFundBase: e.housingBase, + specialDeduction: e.specialDeduction, + bankName: e.bank, + bankAccount: encrypt(e.account), + emergencyContact: e.emergency, + emergencyPhone: e.emergencyPhone, + address: e.address, + isPregnant: e.pregnant || false, + createdBy: admin.id, + }, + }) + + // 创建合同 + const startDate = new Date(e.hireDate) + const endDate = e.contractType === 'FIXED' + ? new Date(startDate.getFullYear() + e.years, startDate.getMonth(), startDate.getDate() - 1) + : null + + await prisma.laborContract.create({ + data: { + orgId: org.id, + employeeId: emp.id, + signDate: new Date(e.hireDate), + startDate, + endDate, + contractType: e.contractType as any, + signMethod: 'PAPER', + contractYears: e.years, + probationMonths: e.probation, + probationSalary: e.probationSalary, + createdBy: admin.id, + }, + }) + console.log(`员工 ${i + 1}/9 已创建: ${e.name} - ${e.dept} - ¥${e.salary}/月`) + } + + // 8. 生成 1-6 月历史工资条(已发布),使 7 月累计预扣个税有 YTD 数据 + console.log('\n生成 1-6 月历史工资条...') + const socialConfig = await prisma.socialInsuranceConfig.findUnique({ where: { orgId: org.id } }) + const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } }) + const months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06'] + + for (const emp of allEmployees) { + // 跳过 2026 年之后入职的员工 + const hireYear = emp.hireDate.getFullYear() + if (hireYear > 2026) continue + const hireMonth = hireYear === 2026 ? emp.hireDate.getMonth() + 1 : 1 + + let ytdIncome = 0, ytdSocialEmp = 0, ytdHousingEmp = 0, ytdTaxDeducted = 0 + + for (let m = 1; m <= 6; m++) { + if (m < hireMonth) continue + const monthStr = `2026-${String(m).padStart(2, '0')}` + const baseSalary = emp.socialInsBase || 0 // 用社保基数作为基本工资(简化) + const social = calcSocial(emp.socialInsBase || baseSalary, socialConfig) + const housing = calcHousing(emp.housingFundBase || baseSalary, socialConfig) + const totalPay = baseSalary + const specialDeduction = emp.specialDeduction * m + + ytdIncome += totalPay + ytdSocialEmp += social.socialEmp + ytdHousingEmp += housing.housingEmp + + const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * m - ytdSocialEmp - ytdHousingEmp - specialDeduction) + const ytdTax = calcTax(ytdTaxableIncome) + const monthTax = Math.max(0, Math.round((ytdTax - ytdTaxDeducted) * 100) / 100) + ytdTaxDeducted += monthTax + + const netPay = Math.round((totalPay - social.socialEmp - housing.housingEmp - monthTax) * 100) / 100 + + await prisma.payslip.create({ + data: { + orgId: org.id, + employeeId: emp.id, + month: monthStr, + baseSalary, + overtimePay: 0, + allowance: 0, + deduction: 0, + bonus: 0, + totalPay, + socialEmp: social.socialEmp, + housingEmp: housing.housingEmp, + tax: monthTax, + netPay, + ytdIncome, + ytdTaxDeducted, + ytdSocialEmp, + ytdHousingEmp, + status: 'PUBLISHED', + publishedAt: new Date(`${monthStr}-10T10:00:00Z`), + confirmedAt: new Date(`${monthStr}-12T10:00:00Z`), + }, + }) + } + console.log(` ${emp.name}: 1-6月工资条已生成`) + } + + console.log('\n===== 示例数据创建完成 =====') + console.log(`企业: ${org.name}`) + console.log(`管理员: 13800000001 / 密码: 12345678`) + console.log(`员工: ${EMPLOYEES.length} 人`) + console.log('社保配置: 上海标准') + console.log('薪酬模版: 10项预置') } main() diff --git a/backend/src/app.ts b/backend/src/app.ts index 83bfcfd..111d881 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -34,6 +34,7 @@ import aiRoutes from './routes/ai.routes' import portalRoutes from './routes/portal.routes' import settingsRoutes from './routes/settings.routes' import payrollRoutes from './routes/payroll.routes' +import payroll2Routes from './routes/payroll2.routes' import socialRoutes from './routes/social.routes' import notificationRoutes from './routes/notification.routes' import attachmentRoutes from './routes/attachment.routes' @@ -46,6 +47,7 @@ app.use('/api/v1/ai', aiRoutes) app.use('/api/v1/portal', portalRoutes) app.use('/api/v1/settings', settingsRoutes) app.use('/api/v1/payroll', payrollRoutes) +app.use('/api/v1/payroll2', payroll2Routes) app.use('/api/v1/social', socialRoutes) app.use('/api/v1/notifications', notificationRoutes) app.use('/api/v1/attachments', attachmentRoutes) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts new file mode 100644 index 0000000..89931b9 --- /dev/null +++ b/backend/src/routes/payroll2.routes.ts @@ -0,0 +1,555 @@ +import { Router, Response, NextFunction } from 'express' +import prisma from '../lib/prisma' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import { z } from 'zod' +import { decrypt } from '../lib/crypto' +import { + ensureDefaultTemplate, + getTemplate, + calcBatchEntry, + getPayrollRiskWarnings, + generatePayslipFromBatches, +} from '../services/payroll.service' + +const router = Router() +router.use(authMiddleware) + +// ========== 薪酬模版 ========== + +// 获取薪酬模版 +router.get('/template', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const items = await getTemplate(req.user!.orgId) + res.json({ success: true, data: items }) + } catch (err) { + next(err) + } +}) + +// 更新薪酬模版项 +const updateTemplateItemSchema = z.object({ + name: z.string().min(1).optional(), + formula: z.string().nullable().optional(), + order: z.number().int().optional(), + isEditable: z.boolean().optional(), +}) + +router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = updateTemplateItemSchema.parse(req.body) + const item = await prisma.payslipItem.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } }) + + const updateData: any = {} + if (data.name !== undefined && !item.isDefault) updateData.name = data.name + if (data.formula !== undefined) updateData.formula = data.formula + if (data.order !== undefined) updateData.order = data.order + if (data.isEditable !== undefined) updateData.isEditable = data.isEditable + + const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 新增薪酬模版项 +const createTemplateItemSchema = z.object({ + name: z.string().min(1), + code: z.string().min(1), + type: z.enum(['INPUT', 'CALCULATED']), + formula: z.string().nullable().optional(), + order: z.number().int().default(99), + isEditable: z.boolean().default(true), +}) + +router.post('/template', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = createTemplateItemSchema.parse(req.body) + const item = await prisma.payslipItem.create({ + data: { ...data, orgId: req.user!.orgId, isDefault: false }, + }) + res.json({ success: true, data: item }) + } catch (err) { + next(err) + } +}) + +// 删除薪酬模版项(仅非预置项) +router.delete('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const item = await prisma.payslipItem.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } }) + if (item.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '预置项不可删除' } }) + + await prisma.payslipItem.delete({ where: { id: req.params.id } }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +// ========== 发薪批次 ========== + +// 检查本月是否已发薪 +router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { month } = req.query + if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } }) + + const archivedBatches = await prisma.payrollBatch.count({ + where: { orgId: req.user!.orgId, month: String(month), status: 'ARCHIVED' }, + }) + const draftBatches = await prisma.payrollBatch.count({ + where: { orgId: req.user!.orgId, month: String(month), status: 'DRAFT' }, + }) + const publishedPayslips = await prisma.payslip.count({ + where: { orgId: req.user!.orgId, month: String(month), status: 'PUBLISHED' }, + }) + + res.json({ + success: true, + data: { + hasArchivedBatch: archivedBatches > 0, + archivedCount: archivedBatches, + draftCount: draftBatches, + payslipsPublished: publishedPayslips > 0, + }, + }) + } catch (err) { + next(err) + } +}) + +// 获取批次列表 +router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { month } = req.query + const batches = await prisma.payrollBatch.findMany({ + where: { + orgId: req.user!.orgId, + ...(month ? { month: String(month) } : {}), + }, + orderBy: [{ month: 'desc' }, { batchNo: 'asc' }], + }) + res.json({ success: true, data: batches }) + } catch (err) { + next(err) + } +}) + +// 获取批次详情 +router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const batch = await prisma.payrollBatch.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + include: { + entries: { + include: { + employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true } }, + }, + orderBy: { employee: { name: 'asc' } }, + }, + }, + }) + if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) + res.json({ success: true, data: batch }) + } catch (err) { + next(err) + } +}) + +// 创建批次 +const createBatchSchema = z.object({ + month: z.string().regex(/^\d{4}-\d{2}$/), + type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'), + name: z.string().optional(), + remark: z.string().optional(), +}) + +router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { month, type, name, remark } = createBatchSchema.parse(req.body) + const orgId = req.user!.orgId + + // 查询当月已有批次数 + const existingBatches = await prisma.payrollBatch.count({ + where: { orgId, month }, + }) + const batchNo = existingBatches + 1 + + // 获取组织发薪频率 + const org = await prisma.organization.findUnique({ where: { id: orgId } }) + + // 获取在职员工 + 本月离职员工 + const monthStart = new Date(`${month}-01`) + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59) + + let employees: any[] + if (type === 'TERMINATION') { + // 离职结算批次:本月离职员工 + const terminations = await prisma.terminationRecord.findMany({ + where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } }, + include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } }, + }) + employees = terminations.map(t => t.employee) + } else { + // 常规/奖金批次:在职 + 本月离职 + employees = await prisma.employee.findMany({ + where: { + orgId, + OR: [ + { status: 'ACTIVE' }, + { status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } }, + ], + }, + include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, + }) + } + + // 获取上月发薪数据作为默认值 + const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1) + const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}` + + const batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : '发薪'}` + + // 创建批次 + const batch = await prisma.payrollBatch.create({ + data: { + orgId, + month, + batchNo, + name: batchName, + type, + remark, + createdBy: req.user!.id, + employeeCount: employees.length, + }, + }) + + // 创建批次条目 + const entries: any[] = [] + for (const emp of employees) { + // 获取上次发薪数据 + const prevPayslip = await prisma.payslip.findUnique({ + where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } }, + }) + + // 获取加班费 + const overtime = await prisma.overtimeRecord.findUnique({ + where: { employeeId_month: { employeeId: emp.id, month } }, + }) + + // 基本工资 + let baseSalary = 0 + if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) { + baseSalary = emp.contracts[0].probationSalary + } else if (emp.monthlySalary) { + try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } + } + + // 如果有上次发薪数据,带入 + if (prevPayslip) { + baseSalary = prevPayslip.baseSalary + } + + const overtimePay = overtime?.totalPay || 0 + const allowance = prevPayslip?.allowance || 0 + const deduction = prevPayslip?.deduction || 0 + const bonus = type === 'BONUS' ? 0 : 0 // 奖金批次默认0,手动填写 + + // 计算社保、个税等 + const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type) + + // 风险提示 + const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id) + + const entry = await prisma.batchEntry.create({ + data: { + batchId: batch.id, + orgId, + employeeId: emp.id, + baseSalary, + overtimePay, + allowance, + deduction, + bonus, + socialEmp: calcResult.socialEmp, + socialOrg: calcResult.socialOrg, + housingEmp: calcResult.housingEmp, + housingOrg: calcResult.housingOrg, + tax: calcResult.tax, + totalPay: calcResult.totalPay, + netPay: calcResult.netPay, + riskWarnings, + }, + }) + entries.push(entry) + } + + // 更新批次汇总 + const totals = entries.reduce((acc, e) => ({ + totalPay: acc.totalPay + e.totalPay, + 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 }) + + const updatedBatch = await prisma.payrollBatch.update({ + where: { id: batch.id }, + 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, + }, + include: { entries: { include: { employee: { select: { id: true, name: true, department: true, status: true } } } } }, + }) + + res.json({ success: true, data: updatedBatch }) + } catch (err) { + next(err) + } +}) + +// 编辑批次条目(计算依据项) +const updateEntrySchema = z.object({ + baseSalary: z.number().min(0).optional(), + overtimePay: z.number().min(0).optional(), + allowance: z.number().min(0).optional(), + deduction: z.number().min(0).optional(), + bonus: z.number().min(0).optional(), +}) + +router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId, employeeId } = req.params + const data = updateEntrySchema.parse(req.body) + 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: 'BAD_REQUEST', message: '已归档批次不可编辑' } }) + + const entry = await prisma.batchEntry.findUnique({ + where: { batchId_employeeId: { batchId, employeeId } }, + }) + if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } }) + + // 合并输入项 + const inputs = { + baseSalary: data.baseSalary ?? entry.baseSalary, + overtimePay: data.overtimePay ?? entry.overtimePay, + allowance: data.allowance ?? entry.allowance, + deduction: data.deduction ?? entry.deduction, + bonus: data.bonus ?? entry.bonus, + } + + // 重新计算 + const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type) + + const updated = await prisma.batchEntry.update({ + where: { id: entry.id }, + data: { ...inputs, ...calcResult }, + }) + + // 更新批次汇总 + const allEntries = await prisma.batchEntry.findMany({ where: { batchId } }) + const totals = allEntries.reduce((acc, e) => ({ + totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay), + totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay), + totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax), + }), { totalPay: 0, totalNetPay: 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, + totalTax: Math.round(totals.totalTax * 100) / 100, + }, + }) + + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 批次增加人员 +router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId } = req.params + const { employeeIds } = req.body as { employeeIds: string[] } + 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: 'BAD_REQUEST', message: '已归档批次不可编辑' } }) + + const results: any[] = [] + for (const employeeId of employeeIds) { + // 检查是否已在批次中 + const existing = await prisma.batchEntry.findUnique({ + where: { batchId_employeeId: { batchId, employeeId } }, + }) + if (existing) continue + + const emp = await prisma.employee.findFirst({ + where: { id: employeeId, orgId }, + include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, + }) + if (!emp) continue + + let baseSalary = 0 + if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) { + baseSalary = emp.contracts[0].probationSalary + } else if (emp.monthlySalary) { + try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } + } + + const overtime = await prisma.overtimeRecord.findUnique({ + where: { employeeId_month: { employeeId, month: batch.month } }, + }) + const overtimePay = overtime?.totalPay || 0 + + const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type) + const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId) + + const entry = await prisma.batchEntry.create({ + data: { + batchId, orgId, employeeId, + baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0, + ...calcResult, riskWarnings, + }, + }) + results.push(entry) + } + + // 更新批次人数 + const count = await prisma.batchEntry.count({ where: { batchId } }) + await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } }) + + res.json({ success: true, data: { added: results.length } }) + } catch (err) { + next(err) + } +}) + +// 批次移除人员 +router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId, employeeId } = 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: 'BAD_REQUEST', message: '已归档批次不可编辑' } }) + + await prisma.batchEntry.deleteMany({ where: { batchId, employeeId } }) + + const count = await prisma.batchEntry.count({ where: { batchId } }) + await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } }) + + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +// 归档批次 +router.post('/batches/:batchId/archive', 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: 'BAD_REQUEST', message: '批次已归档' } }) + + await prisma.payrollBatch.update({ + where: { id: batchId }, + data: { status: 'ARCHIVED', archivedAt: new Date() }, + }) + + res.json({ success: true, data: { archived: true } }) + } catch (err) { + next(err) + } +}) + +// 从已归档批次汇总生成工资条 +router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { month } = req.body + const orgId = req.user!.orgId + + if (!month || !/^\d{4}-\d{2}$/.test(month)) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请提供有效的月份(YYYY-MM)' } }) + } + + // 检查是否有已归档批次 + const archivedBatches = await prisma.payrollBatch.count({ + where: { orgId, month, status: 'ARCHIVED' }, + }) + if (archivedBatches === 0) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月无已归档批次,无法生成工资条' } }) + } + + const result = await generatePayslipFromBatches(orgId, month) + + // 自动标记"生成工资条"待办为已完成 + await prisma.riskItem.updateMany({ + where: { orgId, status: 'PENDING', type: 'SALARY', title: { startsWith: `${month}月 生成工资条` } }, + data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id }, + }) + + res.json({ success: true, data: { generated: result.generated } }) + } catch (err) { + next(err) + } +}) + +// 银行代发文件导出(接口预留) +router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId } = req.params + const orgId = req.user!.orgId + const { format = 'csv' } = req.query + + const batch = await prisma.payrollBatch.findFirst({ + where: { id: batchId, orgId }, + include: { + entries: { + include: { employee: { select: { name: true, bankAccount: true, bankName: true } } }, + }, + }, + }) + 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: 'BAD_REQUEST', message: '仅归档批次可导出' } }) + + if (format === 'csv') { + const header = '姓名,银行账号,开户行,实发金额\n' + const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n') + res.setHeader('Content-Type', 'text/csv; charset=utf-8') + res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`) + return res.send('\ufeff' + header + rows) + } + + res.json({ success: true, data: batch }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index db2867a..2e507e8 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -36,11 +36,14 @@ router.get('/org', async (req: AuthRequest, res, next) => { // 更新企业信息 router.put('/org', async (req: AuthRequest, res, next) => { try { - const { name } = req.body as { name?: string } + const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number } + const updateData: any = {} + if (name) updateData.name = name + if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency const org = await prisma.organization.update({ where: { id: req.user!.orgId }, - data: name ? { name } : {}, - select: { id: true, name: true, plan: true, maxEmployees: true }, + data: updateData, + select: { id: true, name: true, plan: true, maxEmployees: true, payrollFrequency: true }, }) res.json({ success: true, data: org }) } catch (err) { diff --git a/backend/src/schemas/contract.schema.ts b/backend/src/schemas/contract.schema.ts index 1bbb233..557bd1d 100644 --- a/backend/src/schemas/contract.schema.ts +++ b/backend/src/schemas/contract.schema.ts @@ -32,6 +32,9 @@ export const updateEmployeeSchema = z.object({ isPregnant: z.boolean().optional(), isInMedicalPeriod: z.boolean().optional(), isWorkInjured: z.boolean().optional(), + socialInsBase: z.number().min(0).nullable().optional(), + housingFundBase: z.number().min(0).nullable().optional(), + specialDeduction: z.number().min(0).optional(), }) export const batchRenewSchema = z.object({ diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index d092fee..40ef95c 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -212,12 +212,33 @@ export async function updateEmployee(orgId: string, id: string, data: any) { if (data.name !== undefined) updateData.name = data.name if (data.department !== undefined) updateData.department = data.department if (data.hireDate !== undefined) updateData.hireDate = new Date(data.hireDate) - if (data.monthlySalary !== undefined) updateData.monthlySalary = encrypt(data.monthlySalary) + if (data.monthlySalary !== undefined) { + const oldSalary = Number(decrypt(employee.monthlySalary)) || 0 + const newSalary = Number(data.monthlySalary) || 0 + updateData.monthlySalary = encrypt(data.monthlySalary) + // 记录薪资变更 + if (oldSalary !== newSalary) { + await prisma.salaryChangeRecord.create({ + data: { + orgId, + employeeId: id, + oldSalary, + newSalary, + effectiveDate: new Date(), + reason: data.salaryChangeReason || '手动调整', + createdBy: '', + }, + }) + } + } if (data.gender !== undefined) updateData.gender = data.gender if (data.phone !== undefined) updateData.phone = data.phone if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured + if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase + if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase + if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction await prisma.employee.update({ where: { id }, data: updateData }) await runRiskDetection(orgId) diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts new file mode 100644 index 0000000..bda802b --- /dev/null +++ b/backend/src/services/payroll.service.ts @@ -0,0 +1,324 @@ +import prisma from '../lib/prisma' +import { decrypt } from '../lib/crypto' + +// ========== 薪酬模版 ========== + +const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [ + { name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true }, + { name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false }, + { name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true }, + { name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true }, + { name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true }, + { name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false }, + { name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false }, + { name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false }, + { name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false }, + { name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false }, +] + +export async function ensureDefaultTemplate(orgId: string) { + const existing = await prisma.payslipItem.count({ where: { orgId } }) + if (existing === 0) { + await prisma.payslipItem.createMany({ + data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })), + }) + } +} + +export async function getTemplate(orgId: string) { + await ensureDefaultTemplate(orgId) + return prisma.payslipItem.findMany({ + where: { orgId }, + orderBy: { order: 'asc' }, + }) +} + +// ========== 社保计算 ========== + +export function calcSocialInsurance(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100 + const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100 + return { actualBase, socialEmp, socialOrg } +} + +export function calcHousingFund(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const housingEmp = actualBase * config.housingEmp / 100 + const housingOrg = actualBase * config.housingOrg / 100 + return { actualBase, housingEmp, housingOrg } +} + +// ========== 累计预扣个税 ========== + +const TAX_BRACKETS = [ + { rate: 0.03, quickDeduction: 0 }, + { rate: 0.10, quickDeduction: 2520 }, + { rate: 0.20, quickDeduction: 16920 }, + { rate: 0.25, quickDeduction: 31920 }, + { rate: 0.30, quickDeduction: 52920 }, + { rate: 0.35, quickDeduction: 85920 }, + { rate: 0.45, quickDeduction: 181920 }, +] + +export function calcTax(taxableIncome: number): number { + if (taxableIncome <= 0) return 0 + let tax = 0 + if (taxableIncome <= 36000) tax = taxableIncome * 0.03 + else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520 + else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920 + else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920 + else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920 + else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920 + else tax = taxableIncome * 0.45 - 181920 + return Math.max(0, Math.round(tax * 100) / 100) +} + +/** + * 累计预扣法计算当月个税 + * @param ytdTaxableIncome 当年累计应纳税所得额(含当月) + * @param ytdTaxDeducted 当年累计已预扣税额 + * @returns 当月应预扣税额 + */ +export function calcCumulativeTax(ytdTaxableIncome: number, ytdTaxDeducted: number): number { + const ytdTax = calcTax(ytdTaxableIncome) + const currentMonthTax = Math.max(0, ytdTax - ytdTaxDeducted) + return Math.round(currentMonthTax * 100) / 100 +} + +/** + * 年终奖单独计税 + * @param bonusAmount 奖金金额 + * @returns 应纳税额 + */ +export function calcBonusTax(bonusAmount: number): number { + if (bonusAmount <= 0) return 0 + const monthlyBonus = bonusAmount / 12 + let rate = 0.03 + let quickDeduction = 0 + if (monthlyBonus <= 3000) { rate = 0.03; quickDeduction = 0 } + else if (monthlyBonus <= 12000) { rate = 0.10; quickDeduction = 210 } + else if (monthlyBonus <= 25000) { rate = 0.20; quickDeduction = 1410 } + else if (monthlyBonus <= 35000) { rate = 0.25; quickDeduction = 2660 } + else if (monthlyBonus <= 55000) { rate = 0.30; quickDeduction = 4410 } + else if (monthlyBonus <= 80000) { rate = 0.35; quickDeduction = 7160 } + else { rate = 0.45; quickDeduction = 15160 } + const tax = bonusAmount * rate - quickDeduction + return Math.max(0, Math.round(tax * 100) / 100) +} + +// ========== 批次计算 ========== + +export async function calcBatchEntry( + orgId: string, + employeeId: string, + month: string, + inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number }, + batchType: string = 'REGULAR', +) { + const [employee, socialConfig] = await Promise.all([ + prisma.employee.findFirst({ where: { id: employeeId, orgId } }), + prisma.socialInsuranceConfig.findUnique({ where: { orgId } }), + ]) + if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' } + + // 社保基数:优先用员工核定基数,否则用基本工资 + const socialBase = employee.socialInsBase || inputs.baseSalary + const housingBase = employee.housingFundBase || inputs.baseSalary + + let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0 + if (socialConfig) { + const social = calcSocialInsurance(socialBase, socialConfig) + const housing = calcHousingFund(housingBase, socialConfig) + socialEmp = social.socialEmp + socialOrg = social.socialOrg + housingEmp = housing.housingEmp + housingOrg = housing.housingOrg + } + + const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction + + // 个税计算 + let tax = 0 + if (batchType === 'BONUS') { + // 年终奖单独计税 + tax = calcBonusTax(inputs.bonus) + } else { + // 累计预扣法 + const year = month.slice(0, 4) + const prevPayslips = await prisma.payslip.findMany({ + where: { + orgId, + employeeId, + month: { startsWith: year, lt: month }, + }, + select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true }, + }) + const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + totalPay + const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + socialEmp + const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + housingEmp + const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7)) + const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction) + tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted) + } + + const netPay = totalPay - socialEmp - housingEmp - tax + + return { + socialEmp: Math.round(socialEmp * 100) / 100, + socialOrg: Math.round(socialOrg * 100) / 100, + housingEmp: Math.round(housingEmp * 100) / 100, + housingOrg: Math.round(housingOrg * 100) / 100, + tax, + totalPay: Math.round(totalPay * 100) / 100, + netPay: Math.round(netPay * 100) / 100, + } +} + +// ========== 风险提示 ========== + +export async function getPayrollRiskWarnings(orgId: string, employeeId: string): Promise { + const warnings: string[] = [] + const employee = await prisma.employee.findFirst({ + where: { id: employeeId, orgId }, + include: { + contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, + terminations: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }) + if (!employee) return warnings + + if (employee.status === 'RESIGNED') { + warnings.push('该员工已离职,需进行离职结算') + } + if (!employee.contracts.length || employee.contracts[0].contractType === 'UNSIGNED') { + warnings.push('未签订书面劳动合同') + } + if (employee.contracts.length) { + const contract = employee.contracts[0] + if (contract.endDate) { + const daysToExpiry = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + if (daysToExpiry <= 30 && daysToExpiry > 0) { + warnings.push(`合同将于 ${daysToExpiry} 天后到期`) + } + } + if (contract.probationMonths > 0 && contract.startDate) { + const probationEnd = new Date(contract.startDate) + probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths) + if (probationEnd > new Date()) { + warnings.push('试用期员工,薪资可能不同') + } + } + } + if (!employee.socialInsBase) { + warnings.push('未设置社保缴费基数') + } + if (!employee.housingFundBase) { + warnings.push('未设置公积金缴费基数') + } + if (employee.terminations.length) { + warnings.push('已有解聘记录,请注意结算') + } + + return warnings +} + +// ========== 工资条汇总生成 ========== + +export async function generatePayslipFromBatches(orgId: string, month: string) { + // 获取当月所有已归档批次 + const batches = await prisma.payrollBatch.findMany({ + where: { orgId, month, status: 'ARCHIVED' }, + include: { entries: true }, + }) + if (batches.length === 0) return { generated: 0 } + + // 按员工汇总 + const employeeMap = new Map() + for (const batch of batches) { + for (const entry of batch.entries) { + const existing = employeeMap.get(entry.employeeId) || { + baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, + socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, + totalPay: 0, netPay: 0, + } + existing.baseSalary += entry.baseSalary + existing.overtimePay += entry.overtimePay + existing.allowance += entry.allowance + existing.deduction += entry.deduction + existing.bonus += entry.bonus + existing.socialEmp += entry.socialEmp + existing.socialOrg += entry.socialOrg + existing.housingEmp += entry.housingEmp + existing.housingOrg += entry.housingOrg + existing.tax += entry.tax + existing.totalPay += entry.totalPay + existing.netPay += entry.netPay + employeeMap.set(entry.employeeId, existing) + } + } + + // 计算累计数据 + const year = month.slice(0, 4) + const monthNum = Number(month.slice(5, 7)) + + let generated = 0 + for (const [employeeId, summary] of employeeMap) { + // 获取当年之前月份的累计数据 + const prevPayslips = await prisma.payslip.findMany({ + where: { orgId, employeeId, month: { startsWith: year, lt: month } }, + select: { totalPay: true, tax: true, socialEmp: true, housingEmp: true }, + }) + const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + summary.totalPay + const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + summary.tax + const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + summary.socialEmp + const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + summary.housingEmp + + await prisma.payslip.upsert({ + where: { employeeId_month: { employeeId, month } }, + update: { + baseSalary: Math.round(summary.baseSalary * 100) / 100, + overtimePay: Math.round(summary.overtimePay * 100) / 100, + allowance: Math.round(summary.allowance * 100) / 100, + deduction: Math.round(summary.deduction * 100) / 100, + bonus: Math.round(summary.bonus * 100) / 100, + totalPay: Math.round(summary.totalPay * 100) / 100, + socialEmp: Math.round(summary.socialEmp * 100) / 100, + housingEmp: Math.round(summary.housingEmp * 100) / 100, + tax: Math.round(summary.tax * 100) / 100, + netPay: Math.round(summary.netPay * 100) / 100, + ytdIncome: Math.round(ytdIncome * 100) / 100, + ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100, + ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100, + ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100, + status: 'PUBLISHED', + publishedAt: new Date(), + }, + create: { + orgId, + employeeId, + month, + baseSalary: Math.round(summary.baseSalary * 100) / 100, + overtimePay: Math.round(summary.overtimePay * 100) / 100, + allowance: Math.round(summary.allowance * 100) / 100, + deduction: Math.round(summary.deduction * 100) / 100, + bonus: Math.round(summary.bonus * 100) / 100, + totalPay: Math.round(summary.totalPay * 100) / 100, + socialEmp: Math.round(summary.socialEmp * 100) / 100, + housingEmp: Math.round(summary.housingEmp * 100) / 100, + tax: Math.round(summary.tax * 100) / 100, + netPay: Math.round(summary.netPay * 100) / 100, + ytdIncome: Math.round(ytdIncome * 100) / 100, + ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100, + ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100, + ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100, + status: 'PUBLISHED', + publishedAt: new Date(), + }, + }) + generated++ + } + + return { generated } +} diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index 3b5c2fd..fa8049c 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -172,6 +172,21 @@ export async function detectMonthlyTasks(orgId: string) { } } + // 工资条生成提醒:当月有已归档批次时提醒生成工资条 + const archivedBatches = await prisma.payrollBatch.count({ + where: { orgId, month: currentMonth, status: 'ARCHIVED' }, + }) + if (archivedBatches > 0) { + risks.push({ + employeeId: null, + type: 'SALARY', + level: 'MEDIUM', + title: `${currentMonth}月 生成工资条`, + description: `本月有 ${archivedBatches} 个已归档工资批次,请前往工资条管理汇总生成工资条`, + actionUrl: '/money', + }) + } + return risks } @@ -181,10 +196,10 @@ export async function runRiskDetection(orgId: string) { }) const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.title}`)) - // 月度任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的月度任务被重新创建 + // 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建 const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}` const monthlyExisting = await prisma.riskItem.findMany({ - where: { orgId, type: 'MONTHLY', title: { startsWith: `${currentMonth}月` } }, + where: { orgId, title: { startsWith: `${currentMonth}月` } }, select: { employeeId: true, title: true }, }) const monthlyKeys = new Set(monthlyExisting.map((r: typeof monthlyExisting[number]) => `${r.employeeId}:${r.title}`)) @@ -227,12 +242,12 @@ export async function getDashboardData(orgId: string) { const [ employeeCount, highRisks, pendingRisks, riskItems, resolvedItems, - overtimeRecords, payslips, socialConfig, + overtimeRecords, payslips, batchEntries, socialConfig, monthContracts, monthTerminations, monthDisciplinary, monthAttendance, monthSeverancePay, ] = await Promise.all([ prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), - prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH' } }), + prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH', type: { in: ['CONTRACT', 'TERMINATION'] } } }), prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }), prisma.riskItem.findMany({ where: { orgId, status: 'PENDING' }, @@ -254,6 +269,11 @@ export async function getDashboardData(orgId: string) { where: { orgId, month: currentMonth }, select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, totalPay: true, confirmedAt: true }, }), + // 已归档批次的条目(用于总览汇总) + prisma.batchEntry.findMany({ + where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } }, + select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true }, + }), prisma.socialInsuranceConfig.findUnique({ where: { orgId } }), prisma.laborContract.count({ where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } }, @@ -275,20 +295,75 @@ export async function getDashboardData(orgId: string) { const monthlyOvertimePay = overtimeRecords.reduce((sum: number, r: typeof overtimeRecords[number]) => sum + r.totalPay, 0) - // 本月薪税汇总 - const totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0) - const totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0) - const totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0) - const totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0) - const totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0) - const confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length + // 本月薪税汇总:优先从已归档批次汇总,无归档批次则用工资条数据 + const archivedEntries = batchEntries + const useArchivedData = archivedEntries.length > 0 - // 社保公积金估算(基于在职员工数 × 社保配置) + let totalBaseSalary: number, totalOvertimePay: number, totalAllowance: number, totalDeduction: number, totalPay: number + let totalSocialOrg: number, totalSocialEmp: number, totalHousingOrg: number, totalHousingEmp: number, totalTax: number, totalNetPay: number + let payslipCount: number, confirmedPayslips: number + + if (useArchivedData) { + // 从已归档批次条目汇总(同一员工多批次的金额累加) + const empMap = new Map() + for (const e of archivedEntries) { + const ex = empMap.get(e.employeeId) || { baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, totalPay: 0, socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, netPay: 0 } + ex.baseSalary += e.baseSalary + ex.overtimePay += e.overtimePay + ex.allowance += e.allowance + ex.deduction += e.deduction + ex.bonus += e.bonus + ex.totalPay += e.totalPay + ex.socialEmp += e.socialEmp + ex.socialOrg += e.socialOrg + ex.housingEmp += e.housingEmp + ex.housingOrg += e.housingOrg + ex.tax += e.tax + ex.netPay += e.netPay + empMap.set(e.employeeId, ex) + } + const summary = Array.from(empMap.values()) + totalBaseSalary = summary.reduce((s, e) => s + e.baseSalary, 0) + totalOvertimePay = summary.reduce((s, e) => s + e.overtimePay, 0) + totalAllowance = summary.reduce((s, e) => s + e.allowance, 0) + totalDeduction = summary.reduce((s, e) => s + e.deduction, 0) + totalPay = summary.reduce((s, e) => s + e.totalPay, 0) + totalSocialOrg = summary.reduce((s, e) => s + e.socialOrg, 0) + totalSocialEmp = summary.reduce((s, e) => s + e.socialEmp, 0) + totalHousingOrg = summary.reduce((s, e) => s + e.housingOrg, 0) + totalHousingEmp = summary.reduce((s, e) => s + e.housingEmp, 0) + totalTax = summary.reduce((s, e) => s + e.tax, 0) + totalNetPay = summary.reduce((s, e) => s + e.netPay, 0) + payslipCount = summary.length + confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length + } else { + // fallback:从工资条表汇总 + totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0) + totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0) + totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0) + totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0) + totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0) + totalSocialOrg = 0 + totalSocialEmp = 0 + totalHousingOrg = 0 + totalHousingEmp = 0 + totalTax = 0 + totalNetPay = 0 + payslipCount = payslips.length + confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length + } + + // 社保公积金:优先用归档批次的实际计算值,否则估算 let socialOrgTotal = 0 let socialEmpTotal = 0 let housingOrgTotal = 0 let housingEmpTotal = 0 - if (socialConfig && employeeCount > 0) { + if (useArchivedData) { + socialOrgTotal = totalSocialOrg + socialEmpTotal = totalSocialEmp + housingOrgTotal = totalHousingOrg + housingEmpTotal = totalHousingEmp + } else if (socialConfig && employeeCount > 0) { // 用平均工资作为估算基数 const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount @@ -297,11 +372,12 @@ export async function getDashboardData(orgId: string) { housingEmpTotal = avgBase * socialConfig.housingEmp / 100 * employeeCount } - // 个税估算(简化:应纳税所得额 = 税前工资 - 5000起征点 - 社保个人部分 - 公积金个人部分) - const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal) - // 累计预扣法简化:月度个税估算 + // 个税:优先用归档批次的实际计算值,否则估算 let estimatedTax = 0 - if (taxableIncome > 0) { + if (useArchivedData) { + estimatedTax = totalTax + } else { + const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal) if (taxableIncome <= 3000) estimatedTax = taxableIncome * 0.03 else if (taxableIncome <= 12000) estimatedTax = 3000 * 0.03 + (taxableIncome - 3000) * 0.1 else if (taxableIncome <= 25000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + (taxableIncome - 12000) * 0.2 @@ -314,9 +390,9 @@ export async function getDashboardData(orgId: string) { const payrollSummary = { month: currentMonth, employeeCount, - payslipCount: payslips.length, + payslipCount, confirmedPayslips, - unconfirmedPayslips: payslips.length - confirmedPayslips, + unconfirmedPayslips: payslipCount - confirmedPayslips, baseSalary: totalBaseSalary, overtimePay: totalOvertimePay, allowance: totalAllowance, @@ -331,7 +407,7 @@ export async function getDashboardData(orgId: string) { // 企业总成本 = 工资总额 + 企业社保 + 企业公积金 + 经济补偿金 orgTotalCost: totalPay + socialOrgTotal + housingOrgTotal + (monthSeverancePay._sum.compensation || 0), // 员工实发 = 工资总额 - 个人社保 - 个人公积金 - 个税 - empNetPay: totalPay - socialEmpTotal - housingEmpTotal - estimatedTax, + empNetPay: useArchivedData ? totalNetPay : totalPay - socialEmpTotal - housingEmpTotal - estimatedTax, } // 本月工作动态 @@ -353,6 +429,7 @@ export async function getDashboardData(orgId: string) { const todos = riskItems.map((r: typeof riskItems[number]) => ({ id: r.id, + type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY', level: r.level.toLowerCase() as 'high' | 'medium' | 'low', title: r.title, description: r.description, @@ -361,6 +438,7 @@ export async function getDashboardData(orgId: string) { const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({ id: r.id, + type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY', level: r.level.toLowerCase() as 'high' | 'medium' | 'low', title: r.title, description: r.description, diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..031c380 --- /dev/null +++ b/frontend/src/components/ui/Pagination.tsx @@ -0,0 +1,95 @@ +import clsx from 'clsx' +import { ChevronLeft, ChevronRight } from 'lucide-react' + +interface PaginationProps { + page: number // 当前页(1-based) + pageSize: number // 每页条数 + total: number // 总条数 + onPageChange: (page: number) => void + onPageSizeChange?: (size: number) => void + pageSizeOptions?: number[] +} + +export default function Pagination({ + page, + pageSize, + total, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 20, 50], +}: PaginationProps) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const start = total === 0 ? 0 : (page - 1) * pageSize + 1 + const end = Math.min(page * pageSize, total) + + // 生成页码按钮(最多显示 7 个) + const pages: (number | '...')[] = [] + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) pages.push(i) + } else { + pages.push(1) + if (page > 3) pages.push('...') + const s = Math.max(2, page - 1) + const e = Math.min(totalPages - 1, page + 1) + for (let i = s; i <= e; i++) pages.push(i) + if (page < totalPages - 2) pages.push('...') + pages.push(totalPages) + } + + return ( +
+ {/* 左侧:条数信息 + 每页条数选择 */} +
+ 共 {total} 条 + {onPageSizeChange && ( + + )} + 第 {start}-{end} 条 +
+ + {/* 右侧:页码导航 */} +
+ + {pages.map((p, i) => + p === '...' ? ( + + ) : ( + + ), + )} + +
+
+ ) +} diff --git a/frontend/src/pages/Compensation.tsx b/frontend/src/pages/Compensation.tsx index b52ecb7..1593b14 100644 --- a/frontend/src/pages/Compensation.tsx +++ b/frontend/src/pages/Compensation.tsx @@ -6,6 +6,9 @@ import Card from '../components/ui/Card' import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' +// 金额格式化:保留两位小数 + 千分位 +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + interface EmployeeOption { id: string name: string @@ -198,28 +201,28 @@ function SeveranceCalculator() { {result.capped && (
⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月
)} -
计算基数:¥{result.wage.toLocaleString()}/月
+
计算基数:¥{fmt(result.wage)}/月
{result.isIllegal ? '经济补偿金' : '应付金额'} - ¥{result.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(result.basePay)}
{result.isIllegal ? ( <>
违法解除赔偿金(×2) - ¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(result.totalPay)}
-
({result.compMonths}个月 × ¥{result.wage.toLocaleString()} × 2)
+
({result.compMonths}个月 × ¥{fmt(result.wage)} × 2)
) : ( <>
{result.reason} - ¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(result.totalPay)}
-
({result.compMonths}个月 × ¥{result.wage.toLocaleString()}) - {result.noticePay > 0 && 含代通知金 ¥{result.noticePay.toLocaleString()}} +
({result.compMonths}个月 × ¥{fmt(result.wage)}) + {result.noticePay > 0 && 含代通知金 ¥{fmt(result.noticePay)}}
)} @@ -337,9 +340,9 @@ function DoubleSalaryCalculator() {
需赔 - ¥{result.totalPay.toLocaleString()} + ¥{fmt(result.totalPay)}
-
({result.months}个月 × ¥{monthlyWage.toLocaleString()})
+
({result.months}个月 × ¥{fmt(monthlyWage)})
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 05db255..fed326c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,21 +1,40 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo } from 'lucide-react' +import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert } from 'lucide-react' import api from '../lib/api' import Card from '../components/ui/Card' import Button from '../components/ui/Button' import EmptyState from '../components/ui/EmptyState' -import Signal from '../components/ui/Signal' +import Pagination from '../components/ui/Pagination' import type { DashboardData } from '../types' function fmt(n: number) { - return `¥${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}` + return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +const TODO_ICON_CONFIG: Record = { + CONTRACT: { icon: FileText, color: 'text-blue-600', bg: 'bg-blue-50' }, + SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' }, + TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' }, + MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' }, +} + +function TodoIcon({ type, level }: { type: string; level: string }) { + const config = TODO_ICON_CONFIG[type] || TODO_ICON_CONFIG.MONTHLY + const Icon = config.icon + return ( +
+ +
+ ) } export default function Dashboard() { + const [todoPage, setTodoPage] = useState(1) + const [todoPageSize, setTodoPageSize] = useState(10) const queryClient = useQueryClient() - const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'todos'>('overview') + const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview') const { data, isLoading, refetch, isFetching } = useQuery({ queryKey: ['dashboard'], queryFn: async () => { @@ -34,6 +53,10 @@ export default function Dashboard() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }), }) + const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION') || [] + const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || [] + const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos + if (isLoading) { return
加载中...
} @@ -75,7 +98,8 @@ export default function Dashboard() { const tabs = [ { key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount }, { key: 'payroll' as const, label: '薪税', icon: Calculator, badge: payroll?.payslipCount ?? 0 }, - { key: 'todos' as const, label: '待办', icon: ListTodo, badge: data.todos.length }, + { key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length }, + { key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length }, ] return ( @@ -274,27 +298,29 @@ export default function Dashboard() { )} - {/* 待办 Tab */} - {activeTab === 'todos' && ( + {/* 风险提醒 Tab */} + {(activeTab === 'risk' || activeTab === 'task') && (
{/* 待办列表 */}
-

待办事项

- {data.todos.length} 项 +

{activeTab === 'risk' ? '风险提醒' : '月度任务'}

+ {filteredTodos.length} 项
- {data.todos.length === 0 ? ( - + {filteredTodos.length === 0 ? ( + ) : ( -
- {data.todos.map((todo) => ( + <> + { setTodoPageSize(s); setTodoPage(1) }} /> +
+ {filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
- +
{todo.title} {todo.description} @@ -320,7 +346,8 @@ export default function Dashboard() {
))} -
+
+ )}
@@ -338,7 +365,7 @@ export default function Dashboard() { className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50" > - +
{todo.title} {todo.description} diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index b87af6d..f0158d2 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -1,17 +1,24 @@ import { useState, useMemo, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Calculator, AlertCircle, Info, Save, Check, Upload, Zap, Bell } from 'lucide-react' +import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText } from 'lucide-react' import api from '../lib/api' import Card from '../components/ui/Card' import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' +import EmptyState from '../components/ui/EmptyState' +import Pagination from '../components/ui/Pagination' -type Tab = 'overtime' | 'social' | 'payslip' +// 金额格式化:保留两位小数 + 千分位 +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + +type Tab = 'batch' | 'template' | 'overtime' | 'social' | 'payslip' export default function Money() { - const [tab, setTab] = useState('overtime') + const [tab, setTab] = useState('batch') const tabs: { key: Tab; label: string }[] = [ + { key: 'batch', label: '发薪批次' }, + { key: 'template', label: '薪酬模版' }, { key: 'overtime', label: '加班费计算' }, { key: 'social', label: '社保公积金' }, { key: 'payslip', label: '工资条管理' }, @@ -21,12 +28,12 @@ export default function Money() {

薪税计算

-
+
{tabs.map((t) => (
+ {tab === 'batch' && } + {tab === 'template' && } {tab === 'overtime' && } {tab === 'social' && } {tab === 'payslip' && } @@ -42,6 +51,561 @@ export default function Money() { ) } +// ========== 发薪批次管理 ========== + +function BatchManager() { + const queryClient = useQueryClient() + const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) + const [selectedBatchId, setSelectedBatchId] = useState(null) + const [showCreateModal, setShowCreateModal] = useState(false) + const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS'>('REGULAR') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + + const { data: checkResult } = useQuery({ + queryKey: ['batch-check', month], + queryFn: async () => { + const res = await api.get('/payroll2/batches/check', { params: { month } }) as any + return res.data + }, + }) + + const { data: batches, isLoading } = useQuery({ + queryKey: ['batches', month], + queryFn: async () => { + const res = await api.get('/payroll2/batches', { params: { month } }) as any + return res.data + }, + }) + + const createMutation = useMutation({ + mutationFn: (data: any) => api.post('/payroll2/batches', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['batches'] }) + queryClient.invalidateQueries({ queryKey: ['batch-check'] }) + setShowCreateModal(false) + }, + }) + + if (selectedBatchId) { + return setSelectedBatchId(null)} /> + } + + return ( +
+ {/* 重复发薪提醒 */} + {checkResult?.hasArchivedBatch && ( +
+ + 本月已有 {checkResult.archivedCount} 个已归档批次{checkResult.payslipsPublished ? ',工资条已发布' : ''} +
+ )} + +
+
+ setMonth(e.target.value)} className="w-40" /> + {batches && batches.length > 0 && ( + {batches.length} 个批次 + )} +
+
+ +
+
+ + {showCreateModal && ( + +

创建发薪批次

+
+
+ + +
+
+

• 常规发薪:自动拉入在职员工和本月离职员工,带入上次发薪数据

+

• 离职结算:拉入本月离职员工,关联解聘记录

+

• 年终奖/奖金:单独计税,不并入当月工资

+
+
+ + +
+
+
+ )} + + {isLoading ? ( +
加载中...
+ ) : !batches || batches.length === 0 ? ( + + ) : ( +
+ { setPageSize(s); setPage(1) }} /> + {batches.slice((page - 1) * pageSize, page * pageSize).map((batch: any) => ( + +
setSelectedBatchId(batch.id)}> +
+ +
+
{batch.name}
+
+ {batch.employeeCount} 人 · 应发 ¥{fmt(batch.totalPay)} · 实发 ¥{fmt(batch.totalNetPay)} + {batch.type === 'BONUS' && ' · 单独计税'} + {batch.type === 'TERMINATION' && ' · 离职结算'} +
+
+
+
+ {batch.status === 'ARCHIVED' ? ( + + 已归档 + + ) : ( + 草稿 + )} +
+
+
+ ))} +
+ )} +
+ ) +} + +function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) { + const queryClient = useQueryClient() + const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null) + const [editValue, setEditValue] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [showAddEmployee, setShowAddEmployee] = useState(false) + + const { data: batch, isLoading } = useQuery({ + queryKey: ['batch-detail', batchId], + queryFn: async () => { + const res = await api.get(`/payroll2/batches/${batchId}`) as any + return res.data + }, + }) + + const updateEntryMutation = useMutation({ + mutationFn: ({ employeeId, data }: { employeeId: string; data: any }) => + api.put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + }, + }) + + const removeEmployeeMutation = useMutation({ + mutationFn: (employeeId: string) => api.delete(`/payroll2/batches/${batchId}/employees/${employeeId}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['batch-detail'] }), + }) + + const archiveMutation = useMutation({ + mutationFn: () => api.post(`/payroll2/batches/${batchId}/archive`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) + queryClient.invalidateQueries({ queryKey: ['batch-check'] }) + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + alert('批次已归档。归档后批次锁定不可编辑。可前往「工资条管理」生成工资条。') + }, + }) + + if (isLoading) return
加载中...
+ if (!batch) return
批次不存在
+ + const isArchived = batch.status === 'ARCHIVED' + const isBonus = batch.type === 'BONUS' + + // 可编辑的输入项字段 + const editableFields = isBonus + ? ['bonus'] + : ['baseSalary', 'overtimePay', 'allowance', 'deduction', 'bonus'] + + // 点击单元格进入编辑 + const startEdit = (employeeId: string, field: string, currentValue: number) => { + if (isArchived || !editableFields.includes(field)) return + setEditCell({ employeeId, field }) + setEditValue(currentValue.toFixed(2)) + } + + // 保存编辑 + const saveEdit = () => { + if (!editCell) return + const { employeeId, field } = editCell + const numValue = Number(editValue) || 0 + // 找到当前 entry 的其他字段值一起提交 + const entry = batch.entries.find((e: any) => e.employeeId === employeeId) + if (!entry) { setEditCell(null); return } + const data: any = {} + editableFields.forEach(f => { + data[f] = f === field ? numValue : entry[f] + }) + updateEntryMutation.mutate({ employeeId, data }) + setEditCell(null) + } + + // 失焦保存 + const handleBlur = () => saveEdit() + + // Enter 保存,Esc 取消 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); saveEdit() } + if (e.key === 'Escape') setEditCell(null) + } + + // 渲染可编辑单元格 + const renderCell = (entry: any, field: string, className: string = '') => { + const isEditing = editCell?.employeeId === entry.employeeId && editCell?.field === field + const canEdit = !isArchived && editableFields.includes(field) + + const value = entry[field] || 0 + const displayValue = field === 'deduction' && value > 0 ? '-' + fmt(value) : fmt(value) + + if (isEditing) { + return ( + + setEditValue(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + /> + + ) + } + + return ( + canEdit && startEdit(entry.employeeId, field, value)} + > + {canEdit ? ( + 0 ? 'text-danger' : ''}`}> + {displayValue} + + ) : field === 'deduction' && value > 0 ? ( + {displayValue} + ) : ( + displayValue + )} + + ) + } + + return ( +
+
+
+ +

{batch.name}

+ {isArchived ? ( + 已归档 + ) : ( + 草稿 + )} +
+ {!isArchived && ( +
+ + +
+ )} + {isArchived && ( + + + + )} +
+ + {/* 批次汇总 */} +
+ +
+
{batch.employeeCount}
+
人数
+
+
+ +
+
¥{fmt(batch.totalPay)}
+
应发合计
+
+
+ +
+
¥{fmt(batch.totalTax)}
+
个税合计
+
+
+ +
+
¥{fmt(batch.totalNetPay)}
+
实发合计
+
+
+
+ + {/* 添加人员 */} + {showAddEmployee && !isArchived && ( + setShowAddEmployee(false)} /> + )} + + {/* 提示 */} + {!isArchived && ( +
+ + 带下划线的单元格可直接点击编辑,失焦自动保存。灰色列为自动计算项。 +
+ )} + + {/* 人员表格 */} + + {batch.entries.length > 0 && ( + { setPageSize(s); setPage(1) }} /> + )} +
+ + + + + + + + {isBonus && } + {!isBonus && } + + + + + + + + {!isArchived && } + + + + {batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => ( + + + {renderCell(entry, 'baseSalary')} + {renderCell(entry, 'overtimePay')} + {renderCell(entry, 'allowance')} + {renderCell(entry, 'bonus')} + {renderCell(entry, 'deduction')} + {/* 计算项 - 灰显 */} + + + + + + + {!isArchived && ( + + )} + + ))} + +
员工基本工资加班费津贴奖金奖金扣款应发社保公积金个税实发风险操作
+
{entry.employee.name}
+
{entry.employee.department}
+ {entry.employee.status === 'RESIGNED' && ( + 已离职 + )} +
{fmt(entry.totalPay)}{fmt(entry.socialEmp)}{fmt(entry.housingEmp)}{fmt(entry.tax)}{fmt(entry.netPay)} + {entry.riskWarnings && entry.riskWarnings.length > 0 ? ( + + + + ) : ( + + )} + + +
+
+
+
+ ) +} + +function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: () => void }) { + const queryClient = useQueryClient() + const [selected, setSelected] = useState([]) + + const { data: employees } = useQuery({ + queryKey: ['employees-for-batch'], + queryFn: async () => { + const res = await api.get('/employees', { params: { pageSize: 100 } }) as any + return res.data + }, + }) + + const addMutation = useMutation({ + mutationFn: (employeeIds: string[]) => api.post(`/payroll2/batches/${batchId}/employees`, { employeeIds }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + onClose() + }, + }) + + return ( + +
+

添加人员到批次

+ +
+
+ {employees?.items?.map((emp: any) => ( + + ))} +
+
+ + +
+
+ ) +} + +// ========== 薪酬模版管理 ========== + +function TemplateManager() { + const queryClient = useQueryClient() + const { data: items, isLoading } = useQuery({ + queryKey: ['payslip-template'], + queryFn: async () => { + const res = await api.get('/payroll2/template') as any + return res.data + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/payroll2/template/${id}`, data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip-template'] }), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/payroll2/template/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip-template'] }), + }) + + return ( +
+ +
+

薪酬结构模版

+ 定义薪酬项和计算关系 +
+ {isLoading ? ( +
加载中...
+ ) : ( +
+ + + + + + + + + + + + + + {items?.map((item: any) => ( + + + + + + + + + + ))} + +
序号名称字段代码类型计算公式可编辑操作
{item.order}{item.name}{item.code} + + {item.type === 'INPUT' ? '输入项' : '计算项'} + + {item.formula || '—'} + + {item.isEditable ? '可编辑' : '不可编辑'} + + + {!item.isDefault && ( + + )} + {item.isDefault && 预置} +
+
+ )} +

+ 预置项为系统默认薪酬结构,不可删除。计算项的公式支持引用其他字段进行自动计算。 +

+
+
+ ) +} + function OvertimeCalculator() { const queryClient = useQueryClient() const [monthlyWage, setMonthlyWage] = useState(8000) @@ -166,7 +730,7 @@ function OvertimeCalculator() {

计算结果

-
小时工资:¥{result.hourlyWage.toFixed(2)}
+
小时工资:¥{fmt(result.hourlyWage)}
@@ -175,7 +739,7 @@ function OvertimeCalculator() {
合计 - ¥{result.total.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(result.total)}
{result.totalHours > 36 && ( @@ -235,7 +799,7 @@ function ResultRow({ label, value }: { label: string; value: number }) { return (
{label} - ¥{value.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(value)}
) } @@ -243,21 +807,8 @@ function ResultRow({ label, value }: { label: string; value: number }) { function PayslipManager() { const queryClient = useQueryClient() const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) - const [showCreate, setShowCreate] = useState(false) - const [createForm, setCreateForm] = useState({ - employeeId: '', - baseSalary: 8000, - allowance: 0, - deduction: 0, - }) - - const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({ - queryKey: ['employees-for-payslip'], - queryFn: async () => { - const res = await api.get('/employees', { params: { pageSize: 100 } }) as any - return res.data - }, - }) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) const { data: payslips, isLoading } = useQuery({ queryKey: ['payslips', month], @@ -267,24 +818,18 @@ function PayslipManager() { }, }) - const generateMutation = useMutation({ - mutationFn: (data: any) => api.post('/payroll/payslip/generate', data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['payslips'] }) - setShowCreate(false) - }, - }) - const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete(`/payroll/payslip/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }), }) - const batchGenerateMutation = useMutation({ - mutationFn: (data: any) => api.post('/payroll/payslip/batch-generate', data), - onSuccess: () => { + const generateFromBatchMutation = useMutation({ + mutationFn: (data: any) => api.post('/payroll2/payslips/generate', data), + onSuccess: (res: any) => { queryClient.invalidateQueries({ queryKey: ['payslips'] }) - alert('批量生成完成') + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + const n = res?.data?.generated || 0 + alert(`已从归档批次汇总生成 ${n} 条工资条并发布。`) }, }) @@ -305,68 +850,27 @@ function PayslipManager() { )}
-
- {showCreate && ( - -

生成工资条

-
-
- - -
-
- - setCreateForm({ ...createForm, baseSalary: Number(e.target.value) || 0 })} /> -
-
- - setCreateForm({ ...createForm, allowance: Number(e.target.value) || 0 })} /> -
-
- - setCreateForm({ ...createForm, deduction: Number(e.target.value) || 0 })} /> -
-
-
- - -
-
- )} - {isLoading ? (
加载中...
) : !payslips || payslips.length === 0 ? (
该月份暂无工资条记录
) : ( + { setPageSize(s); setPage(1) }} />
@@ -383,15 +887,15 @@ function PayslipManager() { - {payslips.map((p: any) => ( + {payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => ( - - - - - + + + + + - - + + ))} - - + +
{p.employee?.name} {p.employee?.department}¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{p.deduction > 0 ? '-¥' + p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '¥0'}¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}¥{fmt(p.baseSalary)}¥{fmt(p.overtimePay)}¥{fmt(p.allowance)}{p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'}¥{fmt(p.totalPay)} {p.confirmedAt ? ( @@ -549,7 +1053,7 @@ function SocialInsuranceCalculator() { {result ? (
- 缴费基数:¥{result.actualBase.toLocaleString()} + 缴费基数:¥{fmt(result.actualBase)} {result.capped && (已封顶)} {result.floored && (已保底)}
@@ -570,16 +1074,16 @@ function SocialInsuranceCalculator() {
{item.name} {item.orgRate}% {item.empRate}%¥{item.orgAmount.toFixed(2)}¥{item.empAmount.toFixed(2)}¥{fmt(item.orgAmount)}¥{fmt(item.empAmount)}
合计¥{result.totalOrg.toFixed(2)}¥{result.totalEmp.toFixed(2)}¥{fmt(result.totalOrg)}¥{fmt(result.totalEmp)}
@@ -587,10 +1091,10 @@ function SocialInsuranceCalculator() {
总费用 - ¥{result.total.toFixed(2)} + ¥{fmt(result.total)}
- 企业承担 ¥{result.totalOrg.toFixed(2)} + 个人承担 ¥{result.totalEmp.toFixed(2)} + 企业承担 ¥{fmt(result.totalOrg)} + 个人承担 ¥{fmt(result.totalEmp)}
diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index c218568..99f32ca 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -7,6 +7,10 @@ import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import Modal from '../components/ui/Modal' import Signal from '../components/ui/Signal' +import Pagination from '../components/ui/Pagination' + +// 金额格式化:保留两位小数 + 千分位 +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence' @@ -15,6 +19,8 @@ export default function Roster() { const [selectedId, setSelectedId] = useState(null) const [search, setSearch] = useState('') const [showAddModal, setShowAddModal] = useState(false) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) const { data: employees, isLoading } = useQuery({ queryKey: ['roster'], @@ -36,6 +42,7 @@ export default function Roster() { const filtered = employees?.filter((e: any) => !search || e.name.includes(search) || e.department.includes(search) ) || [] + const paged = filtered.slice((page - 1) * pageSize, page * pageSize) if (selectedId) { return setSelectedId(null)} /> @@ -64,6 +71,7 @@ export default function Roster() {
暂无员工
) : ( + { setPageSize(s); setPage(1) }} />
@@ -82,7 +90,7 @@ export default function Roster() { - {filtered.map((e: any) => ( + {paged.map((e: any) => ( - + @@ -204,13 +212,30 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: ( } function BasicInfo({ profile }: { profile: any }) { + const queryClient = useQueryClient() + const [editing, setEditing] = useState(false) + const [form, setForm] = useState({ + socialInsBase: profile.socialInsBase ?? '', + housingFundBase: profile.housingFundBase ?? '', + specialDeduction: profile.specialDeduction ?? 0, + }) + + const updateMutation = useMutation({ + mutationFn: (data: any) => api.put(`/employees/${profile.id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + queryClient.invalidateQueries({ queryKey: ['roster'] }) + setEditing(false) + }, + }) + const fields = [ { label: '姓名', value: profile.name }, { label: '部门', value: profile.department }, { label: '性别', value: profile.gender || '未填写' }, { label: '手机号', value: profile.phone || '未填写' }, { label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) }, - { label: '月工资', value: `¥${profile.monthlySalary.toLocaleString()}` }, + { label: '月工资', value: `¥${fmt(profile.monthlySalary)}` }, { label: '紧急联系人', value: profile.emergencyContact || '未填写' }, { label: '紧急联系电话', value: profile.emergencyPhone || '未填写' }, { label: '住址', value: profile.address || '未填写' }, @@ -225,6 +250,23 @@ function BasicInfo({ profile }: { profile: any }) { ] return ( +
+

基本信息

+ {!editing ? ( + + ) : ( +
+ + +
+ )} +
{fields.map((f) => (
@@ -233,6 +275,44 @@ function BasicInfo({ profile }: { profile: any }) {
))}
+ + {/* 薪税信息 */} +
+

薪税信息

+ {!editing ? ( +
+
+ 社保缴费基数 + {profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'} +
+
+ 公积金缴费基数 + {profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'} +
+
+ 专项附加扣除 + {profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'} +
+
+ ) : ( +
+
+ + setForm({ ...form, socialInsBase: e.target.value })} /> +
+
+ + setForm({ ...form, housingFundBase: e.target.value })} /> +
+
+ + setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} /> +
+
+ )} +

社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。

+
+
{special.map((s) => ( @@ -487,11 +567,11 @@ function PayslipInfo({ payslips }: { payslips: any[] }) { {payslips.map((p) => (
- - - - - + + + + + @@ -524,7 +604,7 @@ function OvertimeInfo({ records }: { records: any[] }) { - + ))} @@ -581,7 +661,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string; 因 {reasonMap[printRecord.reason] || printRecord.reason} 原因,公司决定于 {printRecord.terminationDate?.toString().slice(0, 10)} 起解除与您的劳动合同。

解除依据:{legalBasisMap[printRecord.reason] || ''}

-

经济补偿金:¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}

+

经济补偿金:¥{fmt(printRecord.compensation)}

{printRecord.remark &&

备注:{printRecord.remark}

}

请于解除日期前办理工作交接手续,结清相关费用。

@@ -597,10 +677,10 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
员工{profile.name}({profile.department})
入职日期{profile.hireDate?.toString().slice(0, 10)}
-
月工资¥{profile.monthlySalary.toLocaleString()}/月
+
月工资¥{fmt(profile.monthlySalary)}/月
解聘日期{printRecord.terminationDate?.toString().slice(0, 10)}
解聘原因{reasonMap[printRecord.reason] || printRecord.reason}
-
经济补偿金¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
经济补偿金¥{fmt(printRecord.compensation)}
@@ -678,7 +758,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
解聘日期{t.terminationDate?.toString().slice(0, 10)}
解聘原因{reasonMap[t.reason] || t.reason}
-
经济补偿金¥{t.compensation.toLocaleString()}
+
经济补偿金¥{fmt(t.compensation)}
风险等级{t.riskLevel === 'SAFE' ? '安全' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}
{t.remark &&
备注:{t.remark}
}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 57e6a09..910fc80 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -76,6 +76,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: name: orgData?.data?.name || '', contactName: orgData?.data?.contactName || '', contactPhone: orgData?.data?.contactPhone || '', + payrollFrequency: orgData?.data?.payrollFrequency || 1, }) return ( @@ -94,6 +95,16 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
+
+ + +

设置每月发薪批次数,系统将按此数量管理发薪批次

+
diff --git a/frontend/src/pages/Termination.tsx b/frontend/src/pages/Termination.tsx index be45b73..cf721df 100644 --- a/frontend/src/pages/Termination.tsx +++ b/frontend/src/pages/Termination.tsx @@ -7,6 +7,9 @@ import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import Signal from '../components/ui/Signal' +// 金额格式化:保留两位小数 + 千分位 +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + const REASONS = [ { value: 'NEGOTIATED', label: '协商解除(双方同意分开了)', legalBasis: '《劳动合同法》第36条' }, { value: 'FAULT', label: '员工犯错被辞退(严重违纪/失职等)', legalBasis: '《劳动合同法》第39条' }, @@ -304,7 +307,7 @@ export default function Termination() {
{selectedEmployee.name}({selectedEmployee.department})
入职日期:{selectedEmployee.hireDate?.toString().slice(0, 10)}
-
月工资:¥{selectedEmployee.monthlySalary.toLocaleString()}
+
月工资:¥{fmt(selectedEmployee.monthlySalary)}
{selectedEmployee.latestContract ? (
合同状态:{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}
) : ( @@ -432,7 +435,7 @@ export default function Termination() {
{selectedEmployee?.name}({selectedEmployee?.department})
工作年限:{costResult.years}年{costResult.remainingMonths}个月
-
月工资:¥{costResult.wage.toLocaleString()}/月
+
月工资:¥{fmt(costResult.wage)}/月
{costResult.capped && (
⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月
)} @@ -450,27 +453,27 @@ export default function Termination() { {costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
补偿月数:{costResult.cappedMonths}个月
-
计算基数:¥{costResult.cappedWage.toLocaleString()}/月
+
计算基数:¥{fmt(costResult.cappedWage)}/月
{costResult.isIllegal && (
经济补偿金 - ¥{costResult.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(costResult.basePay)}
)}
{costResult.isIllegal ? '赔偿金(×2)' : '补偿金'} - ¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(costResult.severancePay)}
{costResult.noticePay > 0 && (
代通知金 - ¥{costResult.noticePay.toLocaleString()} + ¥{fmt(costResult.noticePay)}
)} {costResult.noticePay > 0 && ( -
含代通知金 ¥{costResult.noticePay.toLocaleString()}
+
含代通知金 ¥{fmt(costResult.noticePay)}
)} {costResult.isIllegal && (
@@ -493,9 +496,9 @@ export default function Termination() {
赔偿月数:{costResult.doubleMonths}个月
双倍工资赔偿 - ¥{costResult.doublePay.toLocaleString()} + ¥{fmt(costResult.doublePay)}
-
({costResult.doubleMonths}个月 × ¥{costResult.wage.toLocaleString()})
+
({costResult.doubleMonths}个月 × ¥{fmt(costResult.wage)})
入职1个月未签合同,从第2个月起需付双倍工资,最多11个月 @@ -507,7 +510,7 @@ export default function Termination() {
合计应付 - ¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ¥{fmt(costResult.grandTotal)}
@@ -568,9 +571,9 @@ export default function Termination() {

{costResult && !costResult.noComp && (

- 经济补偿金:补偿月数 {costResult.cappedMonths} 个月,计算基数 ¥{costResult.cappedWage.toLocaleString()}/月, - 应付金额 ¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })} - {costResult.noticePay > 0 && `(含代通知金 ¥${costResult.noticePay.toLocaleString()})`} + 经济补偿金:补偿月数 {costResult.cappedMonths} 个月,计算基数 ¥{fmt(costResult.cappedWage)}/月, + 应付金额 ¥{fmt(costResult.severancePay)} + {costResult.noticePay > 0 && `(含代通知金 ¥${fmt(costResult.noticePay)})`} 。

)} @@ -579,11 +582,11 @@ export default function Termination() { )} {costResult && !costResult.hasContract && costResult.doubleMonths > 0 && (

- 未签订劳动合同双倍工资:{costResult.doubleMonths}个月,合计 ¥{costResult.doublePay.toLocaleString()}。 + 未签订劳动合同双倍工资:{costResult.doubleMonths}个月,合计 ¥{fmt(costResult.doublePay)}

)} {costResult && ( -

合计应付金额:¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}

+

合计应付金额:¥{fmt(costResult.grandTotal)}

)}

请于解除日期前办理工作交接手续,结清相关费用。

@@ -599,20 +602,20 @@ export default function Termination() {

费用结算明细

工作年限{costResult.years}年{costResult.remainingMonths}个月
-
月工资¥{costResult.wage.toLocaleString()}/月
+
月工资¥{fmt(costResult.wage)}/月
{costResult.capped &&
⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月
} {!costResult.noComp && ( <>
补偿月数{costResult.cappedMonths}个月
-
计算基数¥{costResult.cappedWage.toLocaleString()}/月
-
{costResult.isIllegal ? '违法解除赔偿金(×2)' : '经济补偿金'}¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}
- {costResult.noticePay > 0 &&
代通知金¥{costResult.noticePay.toLocaleString()}
} +
计算基数¥{fmt(costResult.cappedWage)}/月
+
{costResult.isIllegal ? '违法解除赔偿金(×2)' : '经济补偿金'}¥{fmt(costResult.severancePay)}
+ {costResult.noticePay > 0 &&
代通知金¥{fmt(costResult.noticePay)}
} )} {!costResult.hasContract && costResult.doubleMonths > 0 && ( -
未签合同双倍工资({costResult.doubleMonths}个月)¥{costResult.doublePay.toLocaleString()}
+
未签合同双倍工资({costResult.doubleMonths}个月)¥{fmt(costResult.doublePay)}
)} -
合计应付¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
合计应付¥{fmt(costResult.grandTotal)}
)} diff --git a/frontend/src/pages/portal/ContractConfirm.tsx b/frontend/src/pages/portal/ContractConfirm.tsx index d300fbd..d02b9be 100644 --- a/frontend/src/pages/portal/ContractConfirm.tsx +++ b/frontend/src/pages/portal/ContractConfirm.tsx @@ -5,6 +5,9 @@ import api from '../../lib/api' import Card from '../../components/ui/Card' import Button from '../../components/ui/Button' +// 金额格式化:保留两位小数 + 千分位 +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + export default function ContractConfirm() { const [params] = useSearchParams() const token = params.get('token') || '' @@ -83,7 +86,7 @@ export default function ContractConfirm() { {data.contract.endDate && } {data.contract.probationMonths > 0 && } - {data.contract.probationSalary > 0 && } + {data.contract.probationSalary > 0 && }
)} diff --git a/frontend/src/pages/portal/MyContract.tsx b/frontend/src/pages/portal/MyContract.tsx index c620e1b..787eeed 100644 --- a/frontend/src/pages/portal/MyContract.tsx +++ b/frontend/src/pages/portal/MyContract.tsx @@ -5,6 +5,9 @@ import api from '../../lib/api' import Card from '../../components/ui/Card' import EmptyState from '../../components/ui/EmptyState' +// 金额格式化:保留两位小数 + 千分位 +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + const portalApi = api.create({ baseURL: '/api/v1/portal' }) portalApi.interceptors.request.use((config: any) => { const token = localStorage.getItem('portalToken') @@ -65,7 +68,7 @@ export default function MyContract() { {contract.endDate && } {contract.contractYears > 0 && } {contract.probationMonths > 0 && } - {contract.probationSalary > 0 && } + {contract.probationSalary > 0 && }
{/* 签署确认记录 */} diff --git a/frontend/src/pages/portal/Payslip.tsx b/frontend/src/pages/portal/Payslip.tsx index e5f4d51..3dcc8af 100644 --- a/frontend/src/pages/portal/Payslip.tsx +++ b/frontend/src/pages/portal/Payslip.tsx @@ -7,6 +7,9 @@ import Card from '../../components/ui/Card' import Button from '../../components/ui/Button' import EmptyState from '../../components/ui/EmptyState' +// 金额格式化:保留两位小数 + 千分位 +const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + const portalApi = api.create({ baseURL: '/api/v1/portal' }) portalApi.interceptors.request.use((config: any) => { const token = localStorage.getItem('portalToken') @@ -65,32 +68,32 @@ export default function Payslip() {
基本工资 - ¥{Number(data.baseSalary).toLocaleString()} + ¥{fmt(Number(data.baseSalary))}
{data.overtimePay > 0 && (
加班费 - ¥{Number(data.overtimePay).toLocaleString()} + ¥{fmt(Number(data.overtimePay))}
)} {data.allowance > 0 && (
津贴 - ¥{Number(data.allowance).toLocaleString()} + ¥{fmt(Number(data.allowance))}
)} {data.deduction > 0 && (
扣款 - -¥{Number(data.deduction).toLocaleString()} + -¥{fmt(Number(data.deduction))}
)}
应发合计 - ¥{Number(data.totalPay).toLocaleString()} + ¥{fmt(Number(data.totalPay))}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 97effab..f2a8efc 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -85,6 +85,7 @@ export interface DashboardData { } todos: { id: string + type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' level: 'high' | 'medium' | 'low' title: string description: string @@ -92,6 +93,7 @@ export interface DashboardData { }[] resolvedTodos: { id: string + type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' level: 'high' | 'medium' | 'low' title: string description: string
{e.hireDate?.toString().slice(0, 10)}¥{e.monthlySalary.toLocaleString()}¥{fmt(e.monthlySalary)}
{p.month}¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{p.deduction > 0 ? '-¥' : '¥'}{p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}¥{fmt(p.baseSalary)}¥{fmt(p.overtimePay)}¥{fmt(p.allowance)}{p.deduction > 0 ? '-¥' : '¥'}{fmt(p.deduction)}¥{fmt(p.totalPay)} {p.confirmedAt ? 已确认 : 未确认} {o.weekdayHours} {o.weekendHours} {o.holidayHours}¥{o.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}¥{fmt(o.totalPay)}