feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化
- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页 - Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度) - 待办分为「风险提醒」「月度任务」两个顶层 tab - 归档与工资条生成解耦:归档只锁定批次,工资条单独生成 - 工资条管理新增「从批次汇总生成」按钮 - Dashboard 总览优先从已归档批次 BatchEntry 汇总数据 - 新增工资条生成待办提醒,生成后自动标记完成 - 修复高风险统计只含 CONTRACT/TERMINATION 类型 - 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
@@ -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 {
|
||||
|
||||
+251
-46
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<string, any>()
|
||||
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 }
|
||||
}
|
||||
@@ -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<string, any>()
|
||||
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,
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
{/* 左侧:条数信息 + 每页条数选择 */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>共 {total} 条</span>
|
||||
{onPageSizeChange && (
|
||||
<select
|
||||
className="border rounded px-1 py-0.5 text-xs text-gray-600 focus:outline-none focus:border-primary"
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<option key={n} value={n}>{n} 条/页</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<span>第 {start}-{end} 条</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧:页码导航 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-gray-400 text-xs">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={clsx(
|
||||
'min-w-[28px] h-7 rounded text-xs font-medium transition-colors',
|
||||
p === page
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100',
|
||||
)}
|
||||
onClick={() => onPageChange(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<div className="text-sm text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">计算基数:<span className="text-gray-900">¥{result.wage.toLocaleString()}/月</span></div>
|
||||
<div className="text-sm text-gray-500">计算基数:<span className="text-gray-900">¥{fmt(result.wage)}/月</span></div>
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.isIllegal ? '经济补偿金' : '应付金额'}</span>
|
||||
<span className="font-medium">¥{result.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="font-medium">¥{fmt(result.basePay)}</span>
|
||||
</div>
|
||||
{result.isIllegal ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-danger">违法解除赔偿金(×2)</span>
|
||||
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="text-xl font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{result.wage.toLocaleString()} × 2)</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)} × 2)</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.reason}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{result.wage.toLocaleString()})
|
||||
{result.noticePay > 0 && <span className="block">含代通知金 ¥{result.noticePay.toLocaleString()}</span>}
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)})
|
||||
{result.noticePay > 0 && <span className="block">含代通知金 ¥{fmt(result.noticePay)}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -337,9 +340,9 @@ function DoubleSalaryCalculator() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">需赔</span>
|
||||
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString()}</span>
|
||||
<span className="text-xl font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">({result.months}个月 × ¥{monthlyWage.toLocaleString()})</div>
|
||||
<div className="text-xs text-gray-400 mt-1">({result.months}个月 × ¥{fmt(monthlyWage)})</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
|
||||
@@ -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<string, { icon: typeof FileText; color: string; bg: string }> = {
|
||||
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 (
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<DashboardData>({
|
||||
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 <div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
}
|
||||
@@ -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() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 待办 Tab */}
|
||||
{activeTab === 'todos' && (
|
||||
{/* 风险提醒 Tab */}
|
||||
{(activeTab === 'risk' || activeTab === 'task') && (
|
||||
<div className="space-y-4">
|
||||
{/* 待办列表 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">待办事项</h2>
|
||||
<span className="text-sm text-gray-400">{data.todos.length} 项</span>
|
||||
<h2 className="font-semibold">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
||||
<span className="text-sm text-gray-400">{filteredTodos.length} 项</span>
|
||||
</div>
|
||||
|
||||
{data.todos.length === 0 ? (
|
||||
<EmptyState title="暂无待办" description="所有风险项已处理完毕" />
|
||||
{filteredTodos.length === 0 ? (
|
||||
<EmptyState title="暂无待办" description="所有事项已处理完毕" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.todos.map((todo) => (
|
||||
<>
|
||||
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
|
||||
<div className="space-y-2">
|
||||
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-3 py-3 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
|
||||
<Signal level={todo.level} />
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
@@ -320,7 +346,8 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -338,7 +365,7 @@ export default function Dashboard() {
|
||||
className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
|
||||
<Check className="w-4 h-4 text-safe" />
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-600 line-through">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400">{todo.description}</span>
|
||||
|
||||
+602
-98
@@ -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<Tab>('overtime')
|
||||
const [tab, setTab] = useState<Tab>('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() {
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">薪税计算</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -35,6 +42,8 @@ export default function Money() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'batch' && <BatchManager />}
|
||||
{tab === 'template' && <TemplateManager />}
|
||||
{tab === 'overtime' && <OvertimeCalculator />}
|
||||
{tab === 'social' && <SocialInsuranceCalculator />}
|
||||
{tab === 'payslip' && <PayslipManager />}
|
||||
@@ -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<string | null>(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<any>({
|
||||
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<any[]>({
|
||||
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 <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 重复发薪提醒 */}
|
||||
{checkResult?.hasArchivedBatch && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-amber-50 text-warning text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
本月已有 {checkResult.archivedCount} 个已归档批次{checkResult.payslipsPublished ? ',工资条已发布' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-40" />
|
||||
{batches && batches.length > 0 && (
|
||||
<span className="text-sm text-gray-500">{batches.length} 个批次</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => { setCreateType('REGULAR'); setShowCreateModal(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />创建发薪批次
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">创建发薪批次</h2>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<Label>批次类型</Label>
|
||||
<Select value={createType} onChange={(e) => setCreateType(e.target.value as any)}>
|
||||
<option value="REGULAR">常规发薪</option>
|
||||
<option value="TERMINATION">离职结算</option>
|
||||
<option value="BONUS">年终奖/奖金</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>• 常规发薪:自动拉入在职员工和本月离职员工,带入上次发薪数据</p>
|
||||
<p>• 离职结算:拉入本月离职员工,关联解聘记录</p>
|
||||
<p>• 年终奖/奖金:单独计税,不并入当月工资</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ month, type: createType })}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '确认创建'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowCreateModal(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !batches || batches.length === 0 ? (
|
||||
<EmptyState title="本月暂无发薪批次" description="点击「创建发薪批次」开始" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
{batches.slice((page - 1) * pageSize, page * pageSize).map((batch: any) => (
|
||||
<Card key={batch.id} className="cursor-pointer hover:shadow-md transition-shadow" >
|
||||
<div className="flex items-center justify-between" onClick={() => setSelectedBatchId(batch.id)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">{batch.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{batch.employeeCount} 人 · 应发 ¥{fmt(batch.totalPay)} · 实发 ¥{fmt(batch.totalNetPay)}
|
||||
{batch.type === 'BONUS' && ' · 单独计税'}
|
||||
{batch.type === 'TERMINATION' && ' · 离职结算'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{batch.status === 'ARCHIVED' ? (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe flex items-center gap-1">
|
||||
<Archive className="w-3 h-3" />已归档
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">草稿</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [showAddEmployee, setShowAddEmployee] = useState(false)
|
||||
|
||||
const { data: batch, isLoading } = useQuery<any>({
|
||||
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 <div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
if (!batch) return <div className="text-center py-8 text-gray-400">批次不存在</div>
|
||||
|
||||
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 (
|
||||
<td className="py-1 px-1 text-right" key={field}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoFocus
|
||||
className="w-24 text-right border-b-2 border-primary bg-transparent px-1 py-0.5 text-xs focus:outline-none [appearance:none]"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<td
|
||||
key={field}
|
||||
className={`py-2 px-2 text-right ${className} ${canEdit ? 'cursor-text' : ''}`}
|
||||
onClick={() => canEdit && startEdit(entry.employeeId, field, value)}
|
||||
>
|
||||
{canEdit ? (
|
||||
<span className={`border-b border-dashed border-gray-300 hover:border-primary ${field === 'deduction' && value > 0 ? 'text-danger' : ''}`}>
|
||||
{displayValue}
|
||||
</span>
|
||||
) : field === 'deduction' && value > 0 ? (
|
||||
<span className="text-danger">{displayValue}</span>
|
||||
) : (
|
||||
displayValue
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600 text-sm">← 返回</button>
|
||||
<h2 className="font-semibold">{batch.name}</h2>
|
||||
{isArchived ? (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">已归档</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">草稿</span>
|
||||
)}
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddEmployee(!showAddEmployee)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加人员
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。')) {
|
||||
archiveMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={archiveMutation.isPending}
|
||||
>
|
||||
<Archive className="w-4 h-4 mr-1" />
|
||||
{archiveMutation.isPending ? '归档中...' : '归档'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isArchived && (
|
||||
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Download className="w-4 h-4 mr-1" />银行代发文件
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 批次汇总 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-xl font-bold">{batch.employeeCount}</div>
|
||||
<div className="text-xs text-gray-500">人数</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-primary">¥{fmt(batch.totalPay)}</div>
|
||||
<div className="text-xs text-gray-500">应发合计</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-danger">¥{fmt(batch.totalTax)}</div>
|
||||
<div className="text-xs text-gray-500">个税合计</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-safe">¥{fmt(batch.totalNetPay)}</div>
|
||||
<div className="text-xs text-gray-500">实发合计</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 添加人员 */}
|
||||
{showAddEmployee && !isArchived && (
|
||||
<AddEmployeeToBatch batchId={batchId} onClose={() => setShowAddEmployee(false)} />
|
||||
)}
|
||||
|
||||
{/* 提示 */}
|
||||
{!isArchived && (
|
||||
<div className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
带下划线的单元格可直接点击编辑,失焦自动保存。灰色列为自动计算项。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 人员表格 */}
|
||||
<Card>
|
||||
{batch.entries.length > 0 && (
|
||||
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 px-2">员工</th>
|
||||
<th className="py-2 px-2 text-right">基本工资</th>
|
||||
<th className="py-2 px-2 text-right">加班费</th>
|
||||
<th className="py-2 px-2 text-right">津贴</th>
|
||||
{isBonus && <th className="py-2 px-2 text-right">奖金</th>}
|
||||
{!isBonus && <th className="py-2 px-2 text-right">奖金</th>}
|
||||
<th className="py-2 px-2 text-right">扣款</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">应发</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">社保</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">公积金</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">个税</th>
|
||||
<th className="py-2 px-2 text-right text-gray-400">实发</th>
|
||||
<th className="py-2 px-2">风险</th>
|
||||
{!isArchived && <th className="py-2 px-2">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batch.entries.slice((page - 1) * pageSize, page * pageSize).map((entry: any) => (
|
||||
<tr key={entry.id} className="border-b last:border-0 hover:bg-gray-25">
|
||||
<td className="py-2 px-2">
|
||||
<div className="font-medium">{entry.employee.name}</div>
|
||||
<div className="text-xs text-gray-400">{entry.employee.department}</div>
|
||||
{entry.employee.status === 'RESIGNED' && (
|
||||
<span className="text-xs text-danger">已离职</span>
|
||||
)}
|
||||
</td>
|
||||
{renderCell(entry, 'baseSalary')}
|
||||
{renderCell(entry, 'overtimePay')}
|
||||
{renderCell(entry, 'allowance')}
|
||||
{renderCell(entry, 'bonus')}
|
||||
{renderCell(entry, 'deduction')}
|
||||
{/* 计算项 - 灰显 */}
|
||||
<td className="py-2 px-2 text-right font-medium text-gray-600">{fmt(entry.totalPay)}</td>
|
||||
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.socialEmp)}</td>
|
||||
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.housingEmp)}</td>
|
||||
<td className="py-2 px-2 text-right text-gray-400">{fmt(entry.tax)}</td>
|
||||
<td className="py-2 px-2 text-right font-bold text-safe">{fmt(entry.netPay)}</td>
|
||||
<td className="py-2 px-2">
|
||||
{entry.riskWarnings && entry.riskWarnings.length > 0 ? (
|
||||
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
{!isArchived && (
|
||||
<td className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => removeEmployeeMutation.mutate(entry.employeeId)}
|
||||
className="text-gray-400 hover:text-danger p-1"
|
||||
title="移除"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
|
||||
const { data: employees } = useQuery<any>({
|
||||
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 (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">添加人员到批次</h3>
|
||||
<button onClick={onClose} className="text-gray-400">✕</button>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{employees?.items?.map((emp: any) => (
|
||||
<label key={emp.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(emp.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelected([...selected, emp.id])
|
||||
else setSelected(selected.filter(id => id !== emp.id))
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{emp.name} - {emp.department}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={() => addMutation.mutate(selected)} disabled={selected.length === 0 || addMutation.isPending}>
|
||||
{addMutation.isPending ? '添加中...' : `添加 ${selected.length} 人`}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={onClose}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 薪酬模版管理 ==========
|
||||
|
||||
function TemplateManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: items, isLoading } = useQuery<any[]>({
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-medium">薪酬结构模版</h2>
|
||||
<span className="text-sm text-gray-400">定义薪酬项和计算关系</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-gray-400">加载中...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 px-2">序号</th>
|
||||
<th className="py-2 px-2">名称</th>
|
||||
<th className="py-2 px-2">字段代码</th>
|
||||
<th className="py-2 px-2">类型</th>
|
||||
<th className="py-2 px-2">计算公式</th>
|
||||
<th className="py-2 px-2">可编辑</th>
|
||||
<th className="py-2 px-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items?.map((item: any) => (
|
||||
<tr key={item.id} className="border-b last:border-0">
|
||||
<td className="py-2 px-2 text-gray-400">{item.order}</td>
|
||||
<td className="py-2 px-2 font-medium">{item.name}</td>
|
||||
<td className="py-2 px-2 text-gray-500 font-mono text-xs">{item.code}</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${item.type === 'INPUT' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>
|
||||
{item.type === 'INPUT' ? '输入项' : '计算项'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-gray-500 text-xs font-mono">{item.formula || '—'}</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-400'}`}>
|
||||
{item.isEditable ? '可编辑' : '不可编辑'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
{!item.isDefault && (
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
className="text-xs text-gray-400 hover:text-danger"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
{item.isDefault && <span className="text-xs text-gray-300">预置</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
预置项为系统默认薪酬结构,不可删除。计算项的公式支持引用其他字段进行自动计算。
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OvertimeCalculator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [monthlyWage, setMonthlyWage] = useState(8000)
|
||||
@@ -166,7 +730,7 @@ function OvertimeCalculator() {
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">小时工资:<span className="text-gray-900 font-medium">¥{result.hourlyWage.toFixed(2)}</span></div>
|
||||
<div className="text-sm text-gray-500">小时工资:<span className="text-gray-900 font-medium">¥{fmt(result.hourlyWage)}</span></div>
|
||||
<div className="space-y-2">
|
||||
<ResultRow label={`工作日 ${weekdayHours}h × 1.5`} value={result.weekdayPay} />
|
||||
<ResultRow label={`休息日 ${weekendHours}h × 2.0`} value={result.weekendPay} />
|
||||
@@ -175,7 +739,7 @@ function OvertimeCalculator() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">合计</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.total.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(result.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.totalHours > 36 && (
|
||||
@@ -235,7 +799,7 @@ function ResultRow({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">{label}</span>
|
||||
<span className="font-medium">¥{value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="font-medium">¥{fmt(value)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<any[]>({
|
||||
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() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>生成工资条</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => batchGenerateMutation.mutate({ month })}
|
||||
disabled={batchGenerateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
|
||||
generateFromBatchMutation.mutate({ month })
|
||||
}
|
||||
}}
|
||||
disabled={generateFromBatchMutation.isPending}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
{batchGenerateMutation.isPending ? '生成中...' : '一键全员生成'}
|
||||
<Layers className="w-4 h-4 mr-1" />
|
||||
{generateFromBatchMutation.isPending ? '生成中...' : '从批次汇总生成'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">生成工资条</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={createForm.employeeId} onChange={(e) => setCreateForm({ ...createForm, employeeId: e.target.value })}>
|
||||
<option value="">请选择</option>
|
||||
{employees?.items.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>基本工资</Label>
|
||||
<Input type="number" value={createForm.baseSalary} onChange={(e) => setCreateForm({ ...createForm, baseSalary: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>津贴</Label>
|
||||
<Input type="number" value={createForm.allowance} onChange={(e) => setCreateForm({ ...createForm, allowance: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>扣款</Label>
|
||||
<Input type="number" value={createForm.deduction} onChange={(e) => setCreateForm({ ...createForm, deduction: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
onClick={() => generateMutation.mutate({
|
||||
employeeId: createForm.employeeId,
|
||||
month,
|
||||
baseSalary: createForm.baseSalary,
|
||||
allowance: createForm.allowance,
|
||||
deduction: createForm.deduction,
|
||||
})}
|
||||
disabled={!createForm.employeeId || generateMutation.isPending}
|
||||
>
|
||||
{generateMutation.isPending ? '生成中...' : '确认生成(自动关联加班费)'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !payslips || payslips.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">该月份暂无工资条记录</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -383,15 +887,15 @@ function PayslipManager() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payslips.map((p: any) => (
|
||||
{payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2">{p.employee?.name}</td>
|
||||
<td className="py-2 text-gray-500">{p.employee?.department}</td>
|
||||
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '¥0'}</td>
|
||||
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.baseSalary)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.overtimePay)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.allowance)}</td>
|
||||
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'}</td>
|
||||
<td className="py-2 text-right font-bold">¥{fmt(p.totalPay)}</td>
|
||||
<td className="py-2 text-center">
|
||||
{p.confirmedAt ? (
|
||||
<span className="inline-flex items-center gap-1 text-safe text-xs">
|
||||
@@ -549,7 +1053,7 @@ function SocialInsuranceCalculator() {
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{result.actualBase.toLocaleString()}</span>
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(result.actualBase)}</span>
|
||||
{result.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{result.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
</div>
|
||||
@@ -570,16 +1074,16 @@ function SocialInsuranceCalculator() {
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
|
||||
<td className="py-1.5 text-right">¥{item.orgAmount.toFixed(2)}</td>
|
||||
<td className="py-1.5 text-right">¥{item.empAmount.toFixed(2)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-bold">
|
||||
<td className="py-2" colSpan={3}>合计</td>
|
||||
<td className="py-2 text-right text-danger">¥{result.totalOrg.toFixed(2)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{result.totalEmp.toFixed(2)}</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt(result.totalOrg)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt(result.totalEmp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -587,10 +1091,10 @@ function SocialInsuranceCalculator() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.total.toFixed(2)}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(result.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{result.totalOrg.toFixed(2)} + 个人承担 ¥{result.totalEmp.toFixed(2)}
|
||||
企业承担 ¥{fmt(result.totalOrg)} + 个人承担 ¥{fmt(result.totalEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(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<any[]>({
|
||||
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 <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
|
||||
@@ -64,6 +71,7 @@ export default function Roster() {
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无员工</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -82,7 +90,7 @@ export default function Roster() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((e: any) => (
|
||||
{paged.map((e: any) => (
|
||||
<tr
|
||||
key={e.id}
|
||||
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
|
||||
@@ -96,7 +104,7 @@ export default function Roster() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||||
<td className="py-2 px-3 text-right">¥{e.monthlySalary.toLocaleString()}</td>
|
||||
<td className="py-2 px-3 text-right">¥{fmt(e.monthlySalary)}</td>
|
||||
<td className="py-2 px-3">
|
||||
<Signal level={e.latestContract?.riskLevel || 'safe'} label={e.latestContract ? (e.latestContract.contractType === 'FIXED' ? '固定期限' : e.latestContract.contractType === 'UNFIXED' ? '无固定期限' : '未签') : '无合同'} />
|
||||
</td>
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-medium">基本信息</h2>
|
||||
{!editing ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>编辑薪税信息</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => updateMutation.mutate({
|
||||
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
|
||||
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
|
||||
specialDeduction: Number(form.specialDeduction) || 0,
|
||||
})} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{fields.map((f) => (
|
||||
<div key={f.label} className="flex justify-between border-b pb-2">
|
||||
@@ -233,6 +275,44 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 薪税信息 */}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-3">薪税信息</h3>
|
||||
{!editing ? (
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-500">社保缴费基数</span>
|
||||
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-500">公积金缴费基数</span>
|
||||
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-500">专项附加扣除</span>
|
||||
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>专项附加扣除(元/月)</Label>
|
||||
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-4">
|
||||
{special.map((s) => (
|
||||
<span key={s.label} className={`px-3 py-1 rounded text-sm ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
|
||||
@@ -487,11 +567,11 @@ function PayslipInfo({ payslips }: { payslips: any[] }) {
|
||||
{payslips.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2">{p.month}</td>
|
||||
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">{p.deduction > 0 ? '-¥' : '¥'}{p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.baseSalary)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.overtimePay)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.allowance)}</td>
|
||||
<td className="py-2 text-right">{p.deduction > 0 ? '-¥' : '¥'}{fmt(p.deduction)}</td>
|
||||
<td className="py-2 text-right font-bold">¥{fmt(p.totalPay)}</td>
|
||||
<td className="py-2 text-center">
|
||||
{p.confirmedAt ? <span className="text-safe text-xs">已确认</span> : <span className="text-warning text-xs">未确认</span>}
|
||||
</td>
|
||||
@@ -524,7 +604,7 @@ function OvertimeInfo({ records }: { records: any[] }) {
|
||||
<td className="py-2 text-right">{o.weekdayHours}</td>
|
||||
<td className="py-2 text-right">{o.weekendHours}</td>
|
||||
<td className="py-2 text-right">{o.holidayHours}</td>
|
||||
<td className="py-2 text-right font-medium">¥{o.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right font-medium">¥{fmt(o.totalPay)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -581,7 +661,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
|
||||
因 <strong>{reasonMap[printRecord.reason] || printRecord.reason}</strong> 原因,公司决定于 <strong>{printRecord.terminationDate?.toString().slice(0, 10)}</strong> 起解除与您的劳动合同。
|
||||
</p>
|
||||
<p>解除依据:{legalBasisMap[printRecord.reason] || ''}</p>
|
||||
<p>经济补偿金:<strong>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong></p>
|
||||
<p>经济补偿金:<strong>¥{fmt(printRecord.compensation)}</strong></p>
|
||||
{printRecord.remark && <p>备注:{printRecord.remark}</p>}
|
||||
<p>请于解除日期前办理工作交接手续,结清相关费用。</p>
|
||||
<div className="text-right mt-6 space-y-1">
|
||||
@@ -597,10 +677,10 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between"><span>员工</span><span>{profile.name}({profile.department})</span></div>
|
||||
<div className="flex justify-between"><span>入职日期</span><span>{profile.hireDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{profile.monthlySalary.toLocaleString()}/月</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{fmt(profile.monthlySalary)}/月</span></div>
|
||||
<div className="flex justify-between"><span>解聘日期</span><span>{printRecord.terminationDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span>解聘原因</span><span>{reasonMap[printRecord.reason] || printRecord.reason}</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>经济补偿金</span><span>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>经济补偿金</span><span>¥{fmt(printRecord.compensation)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -678,7 +758,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="flex justify-between"><span className="text-gray-500">解聘日期</span><span className="font-medium">{t.terminationDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">解聘原因</span><span className="font-medium">{reasonMap[t.reason] || t.reason}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">经济补偿金</span><span className="font-medium">¥{t.compensation.toLocaleString()}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">经济补偿金</span><span className="font-medium">¥{fmt(t.compensation)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">风险等级</span><span className="font-medium">{t.riskLevel === 'SAFE' ? '安全' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}</span></div>
|
||||
{t.remark && <div className="md:col-span-2"><span className="text-gray-500">备注:</span><span>{t.remark}</span></div>}
|
||||
<div className="md:col-span-2 flex justify-end">
|
||||
|
||||
@@ -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:
|
||||
<Label>联系电话</Label>
|
||||
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>每月发薪次数</Label>
|
||||
<Select value={String(form.payrollFrequency)} onChange={(e) => setForm({ ...form, payrollFrequency: Number(e.target.value) })}>
|
||||
<option value="1">1次(一月一批)</option>
|
||||
<option value="2">2次(半月一批)</option>
|
||||
<option value="3">3次(旬批)</option>
|
||||
<option value="4">4次(周批)</option>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-400 mt-1">设置每月发薪批次数,系统将按此数量管理发薪批次</p>
|
||||
</div>
|
||||
<Button onClick={() => onSave(form)} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
|
||||
@@ -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() {
|
||||
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="font-medium">{selectedEmployee.name}({selectedEmployee.department})</div>
|
||||
<div>入职日期:{selectedEmployee.hireDate?.toString().slice(0, 10)}</div>
|
||||
<div>月工资:¥{selectedEmployee.monthlySalary.toLocaleString()}</div>
|
||||
<div>月工资:¥{fmt(selectedEmployee.monthlySalary)}</div>
|
||||
{selectedEmployee.latestContract ? (
|
||||
<div>合同状态:{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}</div>
|
||||
) : (
|
||||
@@ -432,7 +435,7 @@ export default function Termination() {
|
||||
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="font-medium">{selectedEmployee?.name}({selectedEmployee?.department})</div>
|
||||
<div>工作年限:{costResult.years}年{costResult.remainingMonths}个月</div>
|
||||
<div>月工资:¥{costResult.wage.toLocaleString()}/月</div>
|
||||
<div>月工资:¥{fmt(costResult.wage)}/月</div>
|
||||
{costResult.capped && (
|
||||
<div className="text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
)}
|
||||
@@ -450,27 +453,27 @@ export default function Termination() {
|
||||
{costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">补偿月数:{costResult.cappedMonths}个月</div>
|
||||
<div className="text-sm text-gray-500">计算基数:¥{costResult.cappedWage.toLocaleString()}/月</div>
|
||||
<div className="text-sm text-gray-500">计算基数:¥{fmt(costResult.cappedWage)}/月</div>
|
||||
{costResult.isIllegal && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">经济补偿金</span>
|
||||
<span>¥{costResult.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span>¥{fmt(costResult.basePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{costResult.isIllegal ? '赔偿金(×2)' : '补偿金'}</span>
|
||||
<span className={`text-lg font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
|
||||
¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
¥{fmt(costResult.severancePay)}
|
||||
</span>
|
||||
</div>
|
||||
{costResult.noticePay > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">代通知金</span>
|
||||
<span>¥{costResult.noticePay.toLocaleString()}</span>
|
||||
<span>¥{fmt(costResult.noticePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costResult.noticePay > 0 && (
|
||||
<div className="text-xs text-gray-400">含代通知金 ¥{costResult.noticePay.toLocaleString()}</div>
|
||||
<div className="text-xs text-gray-400">含代通知金 ¥{fmt(costResult.noticePay)}</div>
|
||||
)}
|
||||
{costResult.isIllegal && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
@@ -493,9 +496,9 @@ export default function Termination() {
|
||||
<div className="text-sm text-gray-500">赔偿月数:{costResult.doubleMonths}个月</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">双倍工资赔偿</span>
|
||||
<span className="text-lg font-bold text-warning">¥{costResult.doublePay.toLocaleString()}</span>
|
||||
<span className="text-lg font-bold text-warning">¥{fmt(costResult.doublePay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({costResult.doubleMonths}个月 × ¥{costResult.wage.toLocaleString()})</div>
|
||||
<div className="text-xs text-gray-400">({costResult.doubleMonths}个月 × ¥{fmt(costResult.wage)})</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-800 text-xs">
|
||||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
<span>入职1个月未签合同,从第2个月起需付双倍工资,最多11个月</span>
|
||||
@@ -507,7 +510,7 @@ export default function Termination() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">合计应付</span>
|
||||
<span className="text-xl font-bold text-danger">¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
<span className="text-xl font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -568,9 +571,9 @@ export default function Termination() {
|
||||
</p>
|
||||
{costResult && !costResult.noComp && (
|
||||
<p>
|
||||
经济补偿金:补偿月数 <strong>{costResult.cappedMonths}</strong> 个月,计算基数 <strong>¥{costResult.cappedWage.toLocaleString()}/月</strong>,
|
||||
应付金额 <strong>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong>
|
||||
{costResult.noticePay > 0 && `(含代通知金 ¥${costResult.noticePay.toLocaleString()})`}
|
||||
经济补偿金:补偿月数 <strong>{costResult.cappedMonths}</strong> 个月,计算基数 <strong>¥{fmt(costResult.cappedWage)}/月</strong>,
|
||||
应付金额 <strong>¥{fmt(costResult.severancePay)}</strong>
|
||||
{costResult.noticePay > 0 && `(含代通知金 ¥${fmt(costResult.noticePay)})`}
|
||||
。
|
||||
</p>
|
||||
)}
|
||||
@@ -579,11 +582,11 @@ export default function Termination() {
|
||||
)}
|
||||
{costResult && !costResult.hasContract && costResult.doubleMonths > 0 && (
|
||||
<p>
|
||||
未签订劳动合同双倍工资:{costResult.doubleMonths}个月,合计 <strong>¥{costResult.doublePay.toLocaleString()}</strong>。
|
||||
未签订劳动合同双倍工资:{costResult.doubleMonths}个月,合计 <strong>¥{fmt(costResult.doublePay)}</strong>。
|
||||
</p>
|
||||
)}
|
||||
{costResult && (
|
||||
<p>合计应付金额:<strong>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong></p>
|
||||
<p>合计应付金额:<strong>¥{fmt(costResult.grandTotal)}</strong></p>
|
||||
)}
|
||||
<p>请于解除日期前办理工作交接手续,结清相关费用。</p>
|
||||
<div className="text-right mt-6 space-y-1">
|
||||
@@ -599,20 +602,20 @@ export default function Termination() {
|
||||
<h3 className="font-medium flex items-center gap-2"><Calculator className="w-4 h-4" />费用结算明细</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between"><span>工作年限</span><span>{costResult.years}年{costResult.remainingMonths}个月</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{costResult.wage.toLocaleString()}/月</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{fmt(costResult.wage)}/月</span></div>
|
||||
{costResult.capped && <div className="text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>}
|
||||
{!costResult.noComp && (
|
||||
<>
|
||||
<div className="flex justify-between"><span>补偿月数</span><span>{costResult.cappedMonths}个月</span></div>
|
||||
<div className="flex justify-between"><span>计算基数</span><span>¥{costResult.cappedWage.toLocaleString()}/月</span></div>
|
||||
<div className="flex justify-between font-medium"><span>{costResult.isIllegal ? '违法解除赔偿金(×2)' : '经济补偿金'}</span><span>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
|
||||
{costResult.noticePay > 0 && <div className="flex justify-between"><span>代通知金</span><span>¥{costResult.noticePay.toLocaleString()}</span></div>}
|
||||
<div className="flex justify-between"><span>计算基数</span><span>¥{fmt(costResult.cappedWage)}/月</span></div>
|
||||
<div className="flex justify-between font-medium"><span>{costResult.isIllegal ? '违法解除赔偿金(×2)' : '经济补偿金'}</span><span>¥{fmt(costResult.severancePay)}</span></div>
|
||||
{costResult.noticePay > 0 && <div className="flex justify-between"><span>代通知金</span><span>¥{fmt(costResult.noticePay)}</span></div>}
|
||||
</>
|
||||
)}
|
||||
{!costResult.hasContract && costResult.doubleMonths > 0 && (
|
||||
<div className="flex justify-between text-warning"><span>未签合同双倍工资({costResult.doubleMonths}个月)</span><span>¥{costResult.doublePay.toLocaleString()}</span></div>
|
||||
<div className="flex justify-between text-warning"><span>未签合同双倍工资({costResult.doubleMonths}个月)</span><span>¥{fmt(costResult.doublePay)}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>合计应付</span><span>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>合计应付</span><span>¥{fmt(costResult.grandTotal)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
<Row label="合同开始" value={new Date(data.contract.startDate).toISOString().slice(0, 10)} />
|
||||
{data.contract.endDate && <Row label="合同结束" value={new Date(data.contract.endDate).toISOString().slice(0, 10)} />}
|
||||
{data.contract.probationMonths > 0 && <Row label="试用期" value={`${data.contract.probationMonths}个月`} />}
|
||||
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${Number(data.contract.probationSalary).toLocaleString()}`} />}
|
||||
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(data.contract.probationSalary))}`} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 && <Row label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />}
|
||||
{contract.contractYears > 0 && <Row label="合同期限" value={`${contract.contractYears}年`} />}
|
||||
{contract.probationMonths > 0 && <Row label="试用期" value={`${contract.probationMonths}个月`} />}
|
||||
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${Number(contract.probationSalary).toLocaleString()}`} />}
|
||||
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />}
|
||||
</div>
|
||||
|
||||
{/* 签署确认记录 */}
|
||||
|
||||
@@ -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() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">基本工资</span>
|
||||
<span className="font-medium">¥{Number(data.baseSalary).toLocaleString()}</span>
|
||||
<span className="font-medium">¥{fmt(Number(data.baseSalary))}</span>
|
||||
</div>
|
||||
{data.overtimePay > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">加班费</span>
|
||||
<span className="font-medium">¥{Number(data.overtimePay).toLocaleString()}</span>
|
||||
<span className="font-medium">¥{fmt(Number(data.overtimePay))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.allowance > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">津贴</span>
|
||||
<span className="font-medium">¥{Number(data.allowance).toLocaleString()}</span>
|
||||
<span className="font-medium">¥{fmt(Number(data.allowance))}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.deduction > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">扣款</span>
|
||||
<span className="font-medium text-danger">-¥{Number(data.deduction).toLocaleString()}</span>
|
||||
<span className="font-medium text-danger">-¥{fmt(Number(data.deduction))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">应发合计</span>
|
||||
<span className="text-xl font-bold text-primary">¥{Number(data.totalPay).toLocaleString()}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user