feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化

- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页
- Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度)
- 待办分为「风险提醒」「月度任务」两个顶层 tab
- 归档与工资条生成解耦:归档只锁定批次,工资条单独生成
- 工资条管理新增「从批次汇总生成」按钮
- Dashboard 总览优先从已归档批次 BatchEntry 汇总数据
- 新增工资条生成待办提醒,生成后自动标记完成
- 修复高风险统计只含 CONTRACT/TERMINATION 类型
- 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
freedakgmail
2026-07-23 13:42:28 +08:00
parent 820579e98d
commit 2a09d31ccc
20 changed files with 2300 additions and 235 deletions
+141 -1
View File
@@ -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
View File
@@ -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()