feat: 社保公积金版本管理 + 员工基数调整 + 加班费计算优化 + UI组件改进

- SocialInsuranceConfig 版本化(effectiveFrom/effectiveTo/isCurrent/adjustmentDone)
- 社保配置版本管理接口(列表/新建/按月获取/当前版本)
- 员工基数调整:预览全部在职员工、上年月均工资计算建议基数、可编辑表格、确认后批量保存
- payroll.service 按批次月份匹配对应版本社保配置
- risk.service / seed.ts 同步更新
- 前端社保tab重构为版本管理+调整+试算
- 加班费计算三步流程、CSV导入、批次导入
- UI组件、Dashboard、合同、薪酬等页面优化
This commit is contained in:
freedakgmail
2026-07-23 16:32:46 +08:00
parent 2a09d31ccc
commit c618710a52
31 changed files with 2245 additions and 745 deletions
+25 -2
View File
@@ -128,8 +128,9 @@ model Organization {
salaryChangeRecords SalaryChangeRecord[]
onboardingLinks OnboardingLink[]
confirmLinks ContractConfirmLink[]
socialInsuranceConfig SocialInsuranceConfig?
socialInsuranceConfig SocialInsuranceConfig[]
notificationSetting NotificationSetting?
overtimeConfig OvertimeConfig?
notificationLogs NotificationLog[]
employeeAttachments EmployeeAttachment[]
disciplinaryRecords DisciplinaryRecord[]
@@ -236,6 +237,7 @@ model OvertimeRecord {
weekendPay Float @default(0)
holidayPay Float @default(0)
totalPay Float @default(0)
batchId String? // 关联的发薪批次(加入后锁定,不可重复加入)
createdAt DateTime @default(now())
@@unique([employeeId, month])
@@ -298,7 +300,7 @@ model AuditLog {
model SocialInsuranceConfig {
id String @id @default(cuid())
orgId String @unique
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
city String @default("北京")
pensionOrg Float @default(16) // 养老保险 企业比例 %
@@ -313,7 +315,16 @@ model SocialInsuranceConfig {
housingEmp Float @default(12) // 公积金 个人比例 %
baseMin Float @default(6326) // 缴费基数下限
baseMax Float @default(33891) // 缴费基数上限
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true) // 是否当前生效版本
adjustmentDone Boolean @default(false) // 是否已执行过员工基数调整
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, effectiveFrom])
@@index([orgId, isCurrent])
}
model NotificationSetting {
@@ -336,6 +347,18 @@ model NotificationSetting {
updatedAt DateTime @updatedAt
}
model OvertimeConfig {
id String @id @default(cuid())
orgId String @unique
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
weekdayRate Float @default(1.5) // 工作日加班倍率
weekendRate Float @default(2.0) // 休息日加班倍率
holidayRate Float @default(3.0) // 法定节假日加班倍率
monthlyDays Float @default(21.75) // 月计薪天数
dailyHours Float @default(8) // 每日工时
updatedAt DateTime @updatedAt
}
model NotificationLog {
id String @id @default(cuid())
orgId String
+120
View File
@@ -0,0 +1,120 @@
import prisma from '../src/lib/prisma'
const EID = 'cmrx61v6d001oqqcwb2pu2tih'
const ORGID = 'cmrx61v3l0000qqcwo3dr3h95'
const UID = 'cmrx61v5u0002qqcwqf4vlyth'
async function main() {
// 加班记录
const otMonths = [
{ month: '2025-03', wh: 8, weh: 4, hh: 0, wp: 600, wep: 600, hp: 0, pay: 1200 },
{ month: '2025-06', wh: 12, weh: 8, hh: 0, wp: 1200, wep: 1200, hp: 0, pay: 2400 },
{ month: '2025-09', wh: 6, weh: 0, hh: 8, wp: 600, wep: 0, hp: 1200, pay: 1800 },
]
for (const o of otMonths) {
const existing = await prisma.overtimeRecord.findUnique({ where: { employeeId_month: { employeeId: EID, month: o.month } } })
if (!existing) {
await prisma.overtimeRecord.create({ data: { orgId: ORGID, employeeId: EID, month: o.month, weekdayHours: o.wh, weekendHours: o.weh, holidayHours: o.hh, weekdayPay: o.wp, weekendPay: o.wep, holidayPay: o.hp, totalPay: o.pay } })
}
}
console.log('加班记录: 完成')
// 违纪记录
const discRecords = [
{ violationDate: new Date('2025-05-12'), violationType: 'LATE', description: '月度迟到超过5次,影响团队考勤', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '口头警告并谈话', employeeAck: true, ackDate: new Date('2025-05-13'), ackMethod: 'SIGN', witness: '王强' },
{ violationDate: new Date('2025-09-20'), violationType: 'ABSENT', description: '未经请假擅自旷工1天', severity: 'SERIOUS', action: 'DEDUCTION', actionDetail: '扣款200元', employeeAck: true, ackDate: new Date('2025-09-21'), ackMethod: 'SIGN', witness: '王强' },
]
for (const d of discRecords) {
const existing = await prisma.disciplinaryRecord.findFirst({ where: { employeeId: EID, violationDate: d.violationDate } })
if (!existing) {
await prisma.disciplinaryRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...d } })
}
}
console.log('违纪记录: 完成')
// 考勤记录 - 最近10个工作日
const attendance = [
{ date: '2026-07-10', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-11', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-14', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-15', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-16', status: 'LATE', late: 25, early: 0 },
{ date: '2026-07-17', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-18', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-21', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-22', status: 'EARLY_LEAVE', late: 0, early: 30 },
{ date: '2026-07-23', status: 'NORMAL', late: 0, early: 0 },
]
for (const a of attendance) {
const existing = await prisma.attendanceRecord.findUnique({ where: { employeeId_date: { employeeId: EID, date: new Date(a.date) } } })
if (!existing) {
await prisma.attendanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, date: new Date(a.date), checkInTime: '09:00', checkOutTime: '18:00', status: a.status, lateMinutes: a.late, earlyMinutes: a.early, workHours: 8, overtimeHours: 0 } })
}
}
console.log('考勤记录: 完成')
// 培训签收记录
const trainings = [
{ trainingDate: new Date('2025-03-15'), topic: '《员工手册》培训', content: '公司规章制度、考勤制度、奖惩条例', trainer: '赵敏', duration: 2, ackStatus: 'SIGNED', ackDate: new Date('2025-03-15'), remark: '新员工入职培训' },
{ trainingDate: new Date('2025-06-20'), topic: '销售技巧与合规培训', content: '销售话术规范、客户信息保护、合同签订注意事项', trainer: '王强', duration: 4, ackStatus: 'SIGNED', ackDate: new Date('2025-06-20') },
{ trainingDate: new Date('2026-01-10'), topic: '2026年度规章制度更新培训', content: '新版考勤制度、绩效考核办法、安全生产规范', trainer: '赵敏', duration: 3, ackStatus: 'PENDING', remark: '待员工签收确认' },
]
for (const t of trainings) {
const existing = await prisma.trainingRecord.findFirst({ where: { employeeId: EID, trainingDate: t.trainingDate } })
if (!existing) {
await prisma.trainingRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...t } })
}
}
console.log('培训记录: 完成')
// 绩效记录
const performances = [
{ period: '2025-Q1', score: 82, grade: 'B', result: 'QUALIFIED', summary: '销售业绩达标,客户维护良好,需提升新客户开发能力', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-04-10'), reviewer: '王强' },
{ period: '2025-Q2', score: 75, grade: 'B', result: 'QUALIFIED', summary: '业绩略有下滑,新客户开发不足,团队协作有待加强', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-07-08'), reviewer: '王强' },
{ period: '2025-Q3', score: 68, grade: 'C', result: 'NEED_IMPROVE', summary: '连续3个月未完成销售目标,客户投诉1次', improvementPlan: '调岗至客户维护岗,加强销售技巧培训1个月', employeeAck: true, ackDate: new Date('2025-10-15'), reviewer: '王强' },
{ period: '2025-Q4', score: 78, grade: 'B', result: 'QUALIFIED', summary: '改进后业绩回升,客户满意度提升', improvementPlan: '', employeeAck: false, reviewer: '王强' },
]
for (const p of performances) {
const existing = await prisma.performanceRecord.findUnique({ where: { employeeId_period: { employeeId: EID, period: p.period } } })
if (!existing) {
await prisma.performanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...p } })
}
}
console.log('绩效记录: 完成')
// 附件
const attachments = [
{ fileName: '吴芳身份证扫描件.pdf', fileType: 'ID_CARD', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 102400 },
{ fileName: '吴芳银行卡复印件.jpg', fileType: 'BANK_CARD', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 51200 },
{ fileName: '吴芳劳动合同扫描件.pdf', fileType: 'CONTRACT_SCAN', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 204800 },
{ fileName: '吴芳学历证书.jpg', fileType: 'EDUCATION', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 81920 },
]
for (const a of attachments) {
const existing = await prisma.employeeAttachment.findFirst({ where: { employeeId: EID, fileName: a.fileName } })
if (!existing) {
await prisma.employeeAttachment.create({ data: { ...a, orgId: ORGID, employeeId: EID, uploadedBy: UID } })
}
}
console.log('附件: 完成')
// 验证
const emp = await prisma.employee.findFirst({
where: { id: EID },
include: { contracts: true, payslips: true, overtimeRecords: true, disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, terminations: true, attachments: true }
})
if (emp) {
console.log('--- 吴芳完整档案数据统计 ---')
console.log('contracts:', emp.contracts.length)
console.log('payslips:', emp.payslips.length)
console.log('overtimeRecords:', emp.overtimeRecords.length)
console.log('disciplinaryRecords:', emp.disciplinaryRecords.length)
console.log('attendanceRecords:', emp.attendanceRecords.length)
console.log('trainingRecords:', emp.trainingRecords.length)
console.log('performanceRecords:', emp.performanceRecords.length)
console.log('terminations:', emp.terminations.length)
console.log('attachments:', emp.attachments.length)
}
await prisma.$disconnect()
}
main().catch(console.error)
+13 -10
View File
@@ -32,15 +32,15 @@ function calcTax(taxableIncome: number): number {
// 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号' },
{ name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', idCard: '310101199001011234', 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', idCard: '310102199203052345', 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', idCard: '310103198812103456', 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', idCard: '310104199506154567', 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', idCard: '310105199907205678', 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', idCard: '310106198504016789', 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', idCard: '310107199311157890', 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', idCard: '310108199008018901', 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', idCard: '310109199702159012', 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() {
@@ -113,6 +113,8 @@ async function main() {
housingEmp: 7,
baseMin: 7384,
baseMax: 36921,
effectiveFrom: '2025-07',
createdBy: admin.id,
},
})
console.log('社保配置已创建')
@@ -164,6 +166,7 @@ async function main() {
hireDate: new Date(e.hireDate),
monthlySalary: encrypt(String(e.salary)),
phone: e.phone,
idCardNumber: encrypt(e.idCard),
gender: e.gender,
socialInsBase: e.socialBase,
housingFundBase: e.housingBase,
@@ -204,7 +207,7 @@ async function main() {
// 8. 生成 1-6 月历史工资条(已发布),使 7 月累计预扣个税有 YTD 数据
console.log('\n生成 1-6 月历史工资条...')
const socialConfig = await prisma.socialInsuranceConfig.findUnique({ where: { orgId: org.id } })
const socialConfig = await prisma.socialInsuranceConfig.findFirst({ where: { orgId: org.id, isCurrent: true } })
const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } })
const months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06']
+116 -10
View File
@@ -283,13 +283,50 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n
}
})
// ========== 批量导入加班数据 ==========
// ========== 加班费计算规则配置 ==========
const overtimeConfigSchema = z.object({
weekdayRate: z.number().min(1).default(1.5),
weekendRate: z.number().min(1).default(2.0),
holidayRate: z.number().min(1).default(3.0),
monthlyDays: z.number().min(1).default(21.75),
dailyHours: z.number().min(1).default(8),
})
// 获取加班费计算规则
router.get('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } })
if (!config) {
config = await prisma.overtimeConfig.create({ data: { orgId: req.user!.orgId } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 保存加班费计算规则
router.post('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = overtimeConfigSchema.parse(req.body)
const config = await prisma.overtimeConfig.upsert({
where: { orgId: req.user!.orgId },
update: data,
create: { orgId: req.user!.orgId, ...data },
})
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// ========== 批量导入加班工时 ==========
const batchOvertimeSchema = z.array(
z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
monthlyWage: z.number().positive(),
weekdayHours: z.number().min(0).default(0),
weekendHours: z.number().min(0).default(0),
holidayHours: z.number().min(0).default(0),
@@ -302,19 +339,13 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
const results: any[] = []
for (const data of items) {
const hourlyWage = data.monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
const weekendPay = hourlyWage * 2.0 * data.weekendHours
const holidayPay = hourlyWage * 3.0 * data.holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
const record = await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
update: {
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay, weekendPay, holidayPay, totalPay,
weekdayPay: 0, weekendPay: 0, holidayPay: 0, totalPay: 0,
},
create: {
orgId: req.user!.orgId,
@@ -323,7 +354,6 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay, weekendPay, holidayPay, totalPay,
},
})
results.push(record)
@@ -335,4 +365,80 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
}
})
// ========== 批次导入加班费 ==========
router.post('/overtime/import-to-batch/:batchId', 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, message: '批次不存在' })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可操作' })
// 获取加班费计算规则
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
const cfg = config
// 获取该月未关联批次的加班记录
const overtimeRecords = await prisma.overtimeRecord.findMany({
where: { orgId, month: batch.month, batchId: null },
include: { employee: { select: { id: true, name: true, monthlySalary: true } } },
})
if (overtimeRecords.length === 0) {
return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' })
}
const results: any[] = []
for (const ot of overtimeRecords) {
// 获取员工月工资
let monthlyWage = 0
try {
monthlyWage = ot.employee.monthlySalary ? Number(decrypt(ot.employee.monthlySalary)) : 0
} catch {
monthlyWage = Number(ot.employee.monthlySalary) || 0
}
if (!monthlyWage) continue
// 根据规则计算加班费
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * ot.weekdayHours
const weekendPay = hourlyWage * config.weekendRate * ot.weekendHours
const holidayPay = hourlyWage * config.holidayRate * ot.holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
// 更新加班记录:计算金额并锁定到批次
await prisma.overtimeRecord.update({
where: { id: ot.id },
data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId },
})
// 更新批次条目的加班费
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } },
})
if (entry) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { overtimePay: totalPay },
})
// 重新计算条目
const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction
await prisma.batchEntry.update({
where: { id: entry.id },
data: { totalPay: newTotalPay },
})
}
results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay })
}
res.json({ success: true, data: { imported: results.length, details: results } })
} catch (err) {
next(err)
}
})
export default router
+146 -52
View File
@@ -125,6 +125,22 @@ router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextF
}
})
// 获取可复制的归档批次列表
router.get('/batches/archived/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const batches = await prisma.payrollBatch.findMany({
where: { orgId, status: 'ARCHIVED' },
orderBy: [{ month: 'desc' }, { batchNo: 'desc' }],
select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true, totalNetPay: true },
take: 20,
})
res.json({ success: true, data: batches })
} catch (err) {
next(err)
}
})
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -167,13 +183,15 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'),
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
sourceBatchId: z.string().optional(),
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 { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
const orgId = req.user!.orgId
// 查询当月已有批次数
@@ -189,34 +207,55 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
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' ? '离职结算' : '发薪'}`
// 根据模式确定员工列表和数据来源
let employees: any[] = []
let sourceEntries: any[] | null = null
if (mode === 'blank_all') {
// 全空白:不拉入员工
employees = []
} else if (mode === 'copy_batch' && sourceBatchId) {
// 复制指定批次:从源批次复制条目
const sourceBatch = await prisma.payrollBatch.findFirst({
where: { id: sourceBatchId, orgId, status: 'ARCHIVED' },
include: { entries: true },
})
if (!sourceBatch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '源批次不存在或未归档' } })
sourceEntries = sourceBatch.entries
// 提取员工 ID,后续按此创建条目
const employeeIds = sourceEntries.map(e => e.employeeId)
employees = await prisma.employee.findMany({
where: { id: { in: employeeIds }, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
} else {
// copy_last 或 blank_employees:拉入员工
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 batch = await prisma.payrollBatch.create({
data: {
@@ -234,36 +273,52 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
// 创建批次条目
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 }
}
let overtimePay = 0
let allowance = 0
let deduction = 0
let bonus = 0
// 如果有上次发薪数据,带入
if (prevPayslip) {
baseSalary = prevPayslip.baseSalary
}
if (mode === 'copy_batch' && sourceEntries) {
// 复制指定批次:从源条目复制数据
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
if (srcEntry) {
baseSalary = srcEntry.baseSalary
overtimePay = srcEntry.overtimePay
allowance = srcEntry.allowance
deduction = srcEntry.deduction
bonus = srcEntry.bonus
}
} else if (mode === 'copy_last') {
// 复制上月:从上月工资条复制
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 } },
})
const overtimePay = overtime?.totalPay || 0
const allowance = prevPayslip?.allowance || 0
const deduction = prevPayslip?.deduction || 0
const bonus = type === 'BONUS' ? 0 : 0 // 奖金批次默认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
overtimePay = overtime?.totalPay || 0
allowance = prevPayslip?.allowance || 0
deduction = prevPayslip?.deduction || 0
}
// blank_employees 和 blank_all: 所有金额默认 0
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
})
// 计算社保、个税等
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
const skipSocial = type !== 'BONUS' && hasArchivedRegularBatch > 0
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
// 风险提示
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
@@ -322,13 +377,17 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
}
})
// 编辑批次条目(计算依据项)
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖
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(),
socialEmp: z.number().min(0).optional(),
socialOrg: z.number().min(0).optional(),
housingEmp: z.number().min(0).optional(),
housingOrg: z.number().min(0).optional(),
})
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
@@ -355,8 +414,16 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
bonus: data.bonus ?? entry.bonus,
}
// 构建社保覆盖参数(如果请求中包含社保字段)
const overrideSocial: any = {}
if (data.socialEmp !== undefined) overrideSocial.socialEmp = data.socialEmp
if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type)
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
@@ -368,14 +435,22 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
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),
totalSocialOrg: acc.totalSocialOrg + (e.id === entry.id ? calcResult.socialOrg : e.socialOrg),
totalSocialEmp: acc.totalSocialEmp + (e.id === entry.id ? calcResult.socialEmp : e.socialEmp),
totalHousingOrg: acc.totalHousingOrg + (e.id === entry.id ? calcResult.housingOrg : e.housingOrg),
totalHousingEmp: acc.totalHousingEmp + (e.id === entry.id ? calcResult.housingEmp : e.housingEmp),
totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax),
}), { totalPay: 0, totalNetPay: 0, totalTax: 0 })
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
@@ -467,6 +542,25 @@ router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest
}
})
// 删除批次(仅限草稿状态)
router.delete('/batches/:batchId', 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.batchEntry.deleteMany({ where: { batchId } })
await prisma.payrollBatch.delete({ where: { id: batchId } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 归档批次
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
+41 -14
View File
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
const router = Router()
@@ -37,18 +38,39 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
},
},
})
const result = employees.map((e) => ({
id: e.id,
name: e.name,
department: e.department,
status: e.status,
hireDate: e.hireDate,
gender: e.gender,
phone: e.phone,
monthlySalary: safeDecrypt(e.monthlySalary),
latestContract: e.contracts[0] || null,
counts: e._count,
}))
const result = employees.map((e) => {
const latestContract = e.contracts[0] || null
const contractInfo = latestContract
? getContractStatus({
signDate: latestContract.signDate,
startDate: latestContract.startDate,
endDate: latestContract.endDate,
contractType: latestContract.contractType,
hireDate: e.hireDate,
})
: getContractStatus({
signDate: null,
startDate: e.hireDate,
endDate: null,
contractType: 'UNSIGNED',
hireDate: e.hireDate,
})
return {
id: e.id,
name: e.name,
department: e.department,
status: e.status,
hireDate: e.hireDate,
gender: e.gender,
phone: e.phone,
monthlySalary: safeDecrypt(e.monthlySalary),
latestContract,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
counts: e._count,
}
})
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -75,10 +97,15 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const { monthlySalary, ...rest } = employee
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
res.json({
success: true,
data: { ...rest, monthlySalary: safeDecrypt(monthlySalary) },
data: {
...rest,
monthlySalary: safeDecrypt(monthlySalary),
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
},
})
} catch (err) {
next(err)
+250 -32
View File
@@ -1,30 +1,13 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { decrypt } from '../lib/crypto'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
// 获取社保配置
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let config = await prisma.socialInsuranceConfig.findUnique({
where: { orgId: req.user!.orgId },
})
if (!config) {
config = await prisma.socialInsuranceConfig.create({
data: { orgId: req.user!.orgId },
})
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 更新社保配置
const configSchema = z.object({
const configFields = {
city: z.string().optional(),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
@@ -38,35 +21,269 @@ const configSchema = z.object({
housingEmp: z.number().optional(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
})
}
router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
// 获取当前生效版本
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = configSchema.parse(req.body)
const config = await prisma.socialInsuranceConfig.upsert({
where: { orgId: req.user!.orgId },
update: data,
create: { orgId: req.user!.orgId, ...data },
let config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
config = await prisma.socialInsuranceConfig.create({
data: {
orgId: req.user!.orgId,
effectiveFrom: new Date().toISOString().slice(0, 7),
createdBy: req.user!.id,
},
})
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 社保计算
// 获取所有版本列表
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const versions = await prisma.socialInsuranceConfig.findMany({
where: { orgId: req.user!.orgId },
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 按月份获取适用版本
router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.params
const config = await prisma.socialInsuranceConfig.findFirst({
where: {
orgId: req.user!.orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
// 回退到当前版本
const current = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
})
return res.json({ success: true, data: current })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 新建版本(年度调基/比例变更)
const createVersionSchema = z.object({
...configFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createVersionSchema.parse(req.body)
const orgId = req.user!.orgId
// 检查同一生效月份是否已有版本
const existing = await prisma.socialInsuranceConfig.findUnique({
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
}
// 将之前当前版本标记为失效
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
})
if (prevCurrent) {
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
const [year, mon] = data.effectiveFrom.split('-').map(Number)
const prevMonth = mon === 1
? `${year - 1}-12`
: `${year}-${String(mon - 1).padStart(2, '0')}`
await prisma.socialInsuranceConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
// 创建新版本
const version = await prisma.socialInsuranceConfig.create({
data: {
orgId,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数)
router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true, name: true, department: true, socialInsBase: true, housingFundBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
// 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值
const now = new Date()
const lastYearStart = `${now.getFullYear() - 1}-01`
const lastYearEnd = `${now.getFullYear() - 1}-12`
const lastYearPayslips = await prisma.payslip.findMany({
where: {
orgId,
month: { gte: lastYearStart, lte: lastYearEnd },
},
select: { employeeId: true, totalPay: true },
})
// 按员工汇总上年月均工资
const avgSalaryMap = new Map<string, number>()
const empPayslipMap = new Map<string, number[]>()
for (const p of lastYearPayslips) {
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
}
for (const [empId, pays] of empPayslipMap) {
const avg = pays.reduce((s, v) => s + v, 0) / pays.length
avgSalaryMap.set(empId, avg)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldSocialBase = emp.socialInsBase ?? monthlyWage
const oldHousingBase = emp.housingFundBase ?? monthlyWage
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
// 建议基数 = 上年月均工资按上下限裁剪
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
const suggestedHousingBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldSocialBase,
oldHousingBase,
avgSalary,
monthlyWage,
suggestedSocialBase,
suggestedHousingBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行员工基数调整(接收用户编辑后的数据)
const adjustApplySchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newSocialBase: z.number(),
newHousingBase: z.number(),
})),
})
router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const { items } = adjustApplySchema.parse(req.body)
let adjusted = 0
for (const item of items) {
// 裁剪到上下限范围内
const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax)
const housingBase = Math.min(Math.max(item.newHousingBase, config.baseMin), config.baseMax)
await prisma.employee.update({
where: { id: item.employeeId },
data: {
socialInsBase: socialBase,
housingFundBase: housingBase,
},
})
adjusted++
}
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// 社保计算(使用当前版本或指定月份版本)
const calcSchema = z.object({
base: z.number().positive(),
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
})
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base } = calcSchema.parse(req.body)
let config = await prisma.socialInsuranceConfig.findUnique({
where: { orgId: req.user!.orgId },
})
const { base, month } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
if (month) {
config = await prisma.socialInsuranceConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.create({ data: { orgId: req.user!.orgId } })
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.create({
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
})
}
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
@@ -93,6 +310,7 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
items: [
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
+5
View File
@@ -29,6 +29,11 @@ export const updateEmployeeSchema = z.object({
monthlySalary: z.string().min(1).optional(),
gender: z.enum(['男', '女']).optional(),
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
bankName: z.string().max(50).optional(),
bankAccount: z.string().max(30).optional(),
emergencyContact: z.string().max(30).optional(),
emergencyPhone: z.string().max(20).optional(),
address: z.string().max(200).optional(),
isPregnant: z.boolean().optional(),
isInMedicalPeriod: z.boolean().optional(),
isWorkInjured: z.boolean().optional(),
+15 -5
View File
@@ -14,11 +14,12 @@ export function getContractStatus(contract: {
hireDate: Date
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
const today = new Date()
const typeLabel = contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : ''
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
const days = daysBetween(today, contract.hireDate)
if (days > 365) {
return { status: 'unsigned_over_year', statusText: '已视为无固定期限', riskLevel: 'high' }
return { status: 'unsigned_over_year', statusText: '未签合同(已视为无固定期限)', riskLevel: 'high' }
} else if (days > 30) {
return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' }
}
@@ -28,14 +29,14 @@ export function getContractStatus(contract: {
if (contract.endDate) {
const daysToExpire = daysBetween(contract.endDate, today)
if (daysToExpire < 0) {
return { status: 'expired', statusText: '已到期未续签', riskLevel: 'high' }
return { status: 'expired', statusText: `${typeLabel}·已到期未续签`, riskLevel: 'high' }
} else if (daysToExpire <= 30) {
return { status: 'expiring', statusText: `即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
return { status: 'expiring', statusText: `${typeLabel}·即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
}
return { status: 'active', statusText: '正常', riskLevel: 'safe' }
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
}
return { status: 'unfixed', statusText: '无固定期限', riskLevel: 'safe' }
return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' }
}
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
@@ -233,6 +234,11 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
}
if (data.gender !== undefined) updateData.gender = data.gender
if (data.phone !== undefined) updateData.phone = data.phone
if (data.bankName !== undefined) updateData.bankName = data.bankName
if (data.bankAccount !== undefined) updateData.bankAccount = encrypt(data.bankAccount)
if (data.emergencyContact !== undefined) updateData.emergencyContact = data.emergencyContact
if (data.emergencyPhone !== undefined) updateData.emergencyPhone = data.emergencyPhone
if (data.address !== undefined) updateData.address = data.address
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
@@ -325,6 +331,10 @@ export async function addContract(orgId: string, userId: string, data: any) {
contractYears: data.contractYears || 3,
probationMonths: data.probationMonths || 0,
probationSalary: data.probationSalary || 0,
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
attachmentUrl: data.attachmentUrl || null,
electronicContractNo: data.electronicContractNo || null,
electronicContractUrl: data.electronicContractUrl || null,
createdBy: userId,
},
})
+28 -8
View File
@@ -115,10 +115,18 @@ export async function calcBatchEntry(
month: string,
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
) {
const [employee, socialConfig] = await Promise.all([
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
prisma.socialInsuranceConfig.findUnique({ where: { orgId } }),
prisma.socialInsuranceConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
])
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
@@ -127,13 +135,25 @@ export async function calcBatchEntry(
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
// 年终奖/奖金批次:不扣社保公积金
if (batchType !== 'BONUS' && !options?.skipSocial) {
if (socialConfig) {
const social = calcSocialInsurance(socialBase, socialConfig)
const housing = calcHousingFund(housingBase, socialConfig)
socialEmp = social.socialEmp
socialOrg = social.socialOrg
housingEmp = housing.housingEmp
housingOrg = housing.housingOrg
}
}
// 手动覆盖社保值
if (options?.overrideSocial) {
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg
if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
}
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
+1 -1
View File
@@ -274,7 +274,7 @@ export async function getDashboardData(orgId: string) {
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.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.laborContract.count({
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
}),
+3 -3
View File
@@ -15,9 +15,9 @@ export default function Button({ variant = 'primary', size = 'md', className, ch
'bg-primary text-white hover:bg-primary-dark': variant === 'primary',
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
'bg-danger text-white hover:bg-red-700': variant === 'danger',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
'px-6 py-3 text-base': size === 'lg',
'px-2.5 py-1 text-xs': size === 'sm',
'px-3 py-1.5 text-xs': size === 'md',
'px-5 py-2 text-sm': size === 'lg',
},
className,
)}
+1 -1
View File
@@ -3,7 +3,7 @@ import clsx from 'clsx'
export default function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-4', className)} {...props}>
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-3', className)} {...props}>
{children}
</div>
)
+5 -5
View File
@@ -12,12 +12,12 @@ interface EmptyStateProps {
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-gray-300 mb-4">
{icon || <Inbox className="w-12 h-12" />}
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="text-gray-300 mb-3">
{icon || <Inbox className="w-10 h-10" />}
</div>
<h3 className="text-base font-medium text-gray-900 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
<h3 className="text-sm font-medium text-gray-900 mb-1">{title}</h3>
{description && <p className="text-xs text-gray-500 mb-3">{description}</p>}
{actionLabel && onAction && (
<Button onClick={onAction}>{actionLabel}</Button>
)}
+3 -3
View File
@@ -7,7 +7,7 @@ export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputE
<input
ref={ref}
className={clsx(
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm',
'w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs',
className,
)}
{...props}
@@ -22,7 +22,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSel
<select
ref={ref}
className={clsx(
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm bg-white',
'w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs bg-white',
className,
)}
{...props}
@@ -34,5 +34,5 @@ export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSel
)
export function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return <label className={clsx('block text-sm font-medium text-gray-700 mb-1', className)}>{children}</label>
return <label className={clsx('block text-xs font-medium text-gray-700 mb-0.5', className)}>{children}</label>
}
+4 -4
View File
@@ -29,14 +29,14 @@ export default function Modal({ open, onClose, title, children, className }: Mod
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto', className)}>
{title && (
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
<h3 className="font-medium text-gray-900">{title}</h3>
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
<h3 className="font-medium text-gray-900 text-sm">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="p-5">{children}</div>
<div className="p-4">{children}</div>
</div>
</div>
)
+10 -4
View File
@@ -6,16 +6,22 @@
body {
@apply bg-surface text-gray-900 antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
}
* {
@apply box-border;
}
h1 { @apply text-base font-semibold; }
h2 { @apply text-sm font-semibold; }
h3 { @apply text-sm font-medium; }
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
@apply inline-flex items-center justify-center px-3 py-1.5 rounded font-medium text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary text-white hover:bg-primary-dark;
@@ -27,12 +33,12 @@
@apply btn bg-danger text-white hover:bg-red-700;
}
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-3;
}
.input {
@apply w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm;
@apply w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
@apply block text-xs font-medium text-gray-700 mb-0.5;
}
}
+9 -9
View File
@@ -31,7 +31,7 @@ export default function AIAssistant() {
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold">AI </h1>
<h1 className="text-xs font-medium">AI </h1>
<div className="flex gap-1 border-b overflow-x-auto">
{tabs.map((t) => {
@@ -40,7 +40,7 @@ export default function AIAssistant() {
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
className={`flex items-center gap-1.5 px-4 py-2 text-xs 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'
}`}
>
@@ -95,7 +95,7 @@ function ChatTab() {
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-sm whitespace-pre-wrap ${
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-xs whitespace-pre-wrap ${
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
}`}>
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
@@ -111,7 +111,7 @@ function ChatTab() {
<button
key={q}
onClick={() => send(q)}
className="px-3 py-1.5 text-sm rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
className="px-3 py-1.5 text-xs rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
>
{q}
</button>
@@ -167,7 +167,7 @@ function PredictTab() {
<Loader2 className="w-5 h-5 animate-spin" /> ...
</div>
) : (
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
)}
<div className="mt-4">
<Button variant="secondary" size="sm" onClick={fetchPrediction} disabled={loading}></Button>
@@ -204,7 +204,7 @@ function ReviewTab() {
</div>
<Label></Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[200px] resize-y"
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[200px] resize-y"
placeholder="粘贴劳动合同文本..."
value={contractText}
onChange={(e) => setContractText(e.target.value)}
@@ -219,7 +219,7 @@ function ReviewTab() {
{result && (
<Card>
<h3 className="font-medium mb-3"></h3>
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
</Card>
)}
</div>
@@ -254,7 +254,7 @@ function CaseTab() {
</div>
<Label></Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[150px] resize-y"
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[150px] resize-y"
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
value={scenario}
onChange={(e) => setScenario(e.target.value)}
@@ -269,7 +269,7 @@ function CaseTab() {
{result && (
<Card>
<h3 className="font-medium mb-3"></h3>
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
</Card>
)}
</div>
+23 -23
View File
@@ -61,15 +61,15 @@ export default function Compensation() {
]
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold"></h1>
<div className="space-y-3">
<h1 className="text-xs font-medium"></h1>
<div className="flex gap-1 border-b">
{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-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
@@ -150,7 +150,7 @@ function SeveranceCalculator() {
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4">
<div className="space-y-3">
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
<div>
<Label></Label>
@@ -189,19 +189,19 @@ function SeveranceCalculator() {
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-sm text-gray-500"><span className="text-gray-900">{result.reason}</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900">{result.years}{result.remainingMonths}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.reason}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.years}{result.remainingMonths}</span></div>
{result.noComp ? (
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-sm">
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
{result.reasonNote}
</div>
) : (
<>
<div className="text-sm text-gray-500"><span className="text-gray-900">{result.compMonths}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.compMonths}</span></div>
{result.capped && (
<div className="text-sm text-warning"> 312</div>
<div className="text-xs text-warning"> 312</div>
)}
<div className="text-sm text-gray-500"><span className="text-gray-900">¥{fmt(result.wage)}/</span></div>
<div className="text-xs 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>
@@ -211,7 +211,7 @@ function SeveranceCalculator() {
<>
<div className="flex items-center justify-between">
<span className="font-medium text-danger">×2</span>
<span className="text-xl font-bold text-danger">¥{fmt(result.totalPay)}</span>
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{fmt(result.wage)} × 2</div>
</>
@@ -219,7 +219,7 @@ function SeveranceCalculator() {
<>
<div className="flex items-center justify-between">
<span className="font-medium">{result.reason}</span>
<span className="text-xl font-bold text-primary">¥{fmt(result.totalPay)}</span>
<span className="text-lg font-bold text-primary">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{fmt(result.wage)})
{result.noticePay > 0 && <span className="block"> ¥{fmt(result.noticePay)}</span>}
@@ -228,12 +228,12 @@ function SeveranceCalculator() {
)}
</div>
{result.reasonNote && (
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-sm ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-xs ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>{result.reasonNote}</span>
</div>
)}
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>116116</span>
</div>
@@ -241,7 +241,7 @@ function SeveranceCalculator() {
)}
</div>
) : (
<div className="text-gray-400 text-sm"></div>
<div className="text-gray-400 text-xs"></div>
)}
</Card>
</div>
@@ -303,7 +303,7 @@ function DoubleSalaryCalculator() {
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4">
<div className="space-y-3">
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
<div>
<Label></Label>
@@ -333,24 +333,24 @@ function DoubleSalaryCalculator() {
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-sm text-gray-500"><span className="text-gray-900">{hireDate}</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{hireDate}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></div>
<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">¥{fmt(result.totalPay)}</span>
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
</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">
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>1211</span>
</div>
</div>
) : (
<div className="text-gray-400 text-sm"></div>
<div className="text-gray-400 text-xs"></div>
)}
</Card>
</div>
+33 -21
View File
@@ -91,7 +91,7 @@ export default function Contracts() {
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 px-3 font-medium"></th>
@@ -108,10 +108,19 @@ export default function Contracts() {
<td className="py-3 px-3 text-gray-600">{emp.department}</td>
<td className="py-3 px-3 text-gray-600">{emp.hireDate}</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<Signal level={emp.riskLevel} />
<span>{emp.contractStatusText}</span>
</div>
{(() => {
const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger',
unsigned_over_year: 'bg-red-50 text-danger',
unsigned_over_30: 'bg-red-50 text-danger',
unsigned: 'bg-yellow-50 text-yellow-700',
expiring: 'bg-yellow-50 text-yellow-700',
active: 'bg-green-50 text-safe',
unfixed: 'bg-blue-50 text-blue-700',
}
const style = tagStyles[emp.contractStatus] || 'bg-gray-100 text-gray-600'
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{emp.contractStatusText}</span>
})()}
</td>
<td className="py-3 px-3">
<div className="flex gap-1">
@@ -135,7 +144,7 @@ export default function Contracts() {
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
></Button>
<span className="text-sm text-gray-500">{page} / {data.totalPages}</span>
<span className="text-xs text-gray-500">{page} / {data.totalPages}</span>
<Button
variant="secondary"
size="sm"
@@ -221,7 +230,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
<Modal open={open} onClose={onClose} title="添加员工">
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{error.response?.data?.error?.message || '操作失败'}
</div>
)}
@@ -264,15 +273,15 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
{/* 特殊状态 */}
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-sm">
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
/
</label>
<label className="flex items-center gap-1.5 text-sm">
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
</label>
<label className="flex items-center gap-1.5 text-sm">
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
</label>
@@ -392,7 +401,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
return (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
<div className="relative w-full max-w-md bg-white h-full overflow-y-auto shadow-xl">
<div className="relative w-full max-w-2xl bg-white h-full overflow-y-auto shadow-xl">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 sticky top-0 bg-white z-10">
<h3 className="font-medium text-gray-900"></h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
@@ -406,9 +415,9 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<div className="space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{emp.name}</h2>
<span className="text-sm text-gray-500">{emp.department}</span>
<span className="text-xs text-gray-500">{emp.department}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-gray-400"></span>{emp.hireDate?.slice(0, 10)}</div>
<div><span className="text-gray-400"></span>{emp.gender || '-'}</div>
<div><span className="text-gray-400"></span>{emp.phone || '-'}</div>
@@ -425,13 +434,16 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
{emp.contracts && emp.contracts.length > 0 && (
<div className="border-t pt-3">
<h3 className="font-medium text-sm mb-2"></h3>
<div className="space-y-2 text-sm">
<h3 className="font-medium text-xs mb-2"></h3>
<div className="space-y-2 text-xs">
{emp.contracts.map((c: any) => (
<div key={c.id} className="bg-gray-50 rounded p-2">
<div className="flex items-center gap-2">
<Signal level={c.riskLevel || 'safe'} />
<span>{c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'}</span>
{(() => {
const typeLabel = c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'
const typeStyle = c.contractType === 'UNSIGNED' ? 'bg-red-50 text-danger' : 'bg-blue-50 text-blue-700'
return <span className={`px-2 py-0.5 rounded text-xs ${typeStyle}`}>{typeLabel}</span>
})()}
</div>
<div className="text-gray-500 text-xs mt-1">
{c.startDate?.slice(0, 10)} ~ {c.endDate?.slice(0, 10) || '无固定期限'}
@@ -446,13 +458,13 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<div className="border-t pt-3">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm flex items-center gap-1">
<h3 className="font-medium text-xs flex items-center gap-1">
<Paperclip className="w-4 h-4" />
</h3>
</div>
<div className="flex gap-2 mb-3">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-sm">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="CONTRACT_SCAN"></option>
@@ -478,7 +490,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
{attachments && attachments.length > 0 ? (
<div className="space-y-2">
{attachments.map((att: any) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-sm">
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
@@ -498,7 +510,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
))}
</div>
) : (
<div className="text-gray-400 text-sm text-center py-4"></div>
<div className="text-gray-400 text-xs text-center py-4"></div>
)}
</div>
</div>
+53 -53
View File
@@ -103,11 +103,11 @@ export default function Dashboard() {
]
return (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">{data.greeting}</h1>
<p className="text-sm text-gray-500 mt-0.5">{payroll?.month} </p>
<h1 className="text-xs font-medium">{data.greeting}</h1>
<p className="text-xs text-gray-500 mt-0.5">{payroll?.month} </p>
</div>
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
@@ -123,7 +123,7 @@ export default function Dashboard() {
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
@@ -141,16 +141,16 @@ export default function Dashboard() {
{/* 概览 Tab */}
{activeTab === 'overview' && (
<div className="space-y-4">
<div className="space-y-3">
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((stat) => {
const Icon = stat.icon
return (
<Card key={stat.label} className="flex items-center gap-3">
<Icon className={`w-8 h-8 ${stat.color}`} />
<Card key={stat.label} className="flex items-center gap-2.5">
<Icon className={`w-6 h-6 ${stat.color}`} />
<div>
<div className="text-xl font-bold">{stat.value}</div>
<div className="text-base font-bold">{stat.value}</div>
<div className="text-xs text-gray-500">{stat.label}</div>
</div>
</Card>
@@ -160,17 +160,17 @@ export default function Dashboard() {
{/* 本月工作动态 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><Briefcase className="w-5 h-5" /></h2>
<span className="text-sm text-gray-400">{activities?.month}</span>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" /></h2>
<span className="text-xs text-gray-400">{activities?.month}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
{activityItems.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex flex-col items-center p-3 rounded-lg bg-gray-50">
<Icon className={`w-5 h-5 mb-1 ${item.color}`} />
<div className="text-lg font-bold">{item.value}</div>
<div key={item.label} className="flex flex-col items-center p-2 rounded-lg bg-gray-50">
<Icon className={`w-4 h-4 mb-1 ${item.color}`} />
<div className="text-xs font-bold">{item.value}</div>
<div className="text-xs text-gray-500">{item.label}</div>
</div>
)
@@ -180,18 +180,18 @@ export default function Dashboard() {
{/* 风险分布 */}
<Card>
<h2 className="font-semibold mb-4"></h2>
<div className="grid grid-cols-3 gap-4">
<h2 className="font-medium mb-3"></h2>
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<div className="text-2xl font-bold text-primary">{data.riskDistribution.contract}</div>
<div className="text-lg font-bold text-primary">{data.riskDistribution.contract}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-warning">{data.riskDistribution.salary}</div>
<div className="text-lg font-bold text-warning">{data.riskDistribution.salary}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-danger">{data.riskDistribution.termination}</div>
<div className="text-lg font-bold text-danger">{data.riskDistribution.termination}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
</div>
@@ -202,18 +202,18 @@ export default function Dashboard() {
{/* 薪税 Tab */}
{activeTab === 'payroll' && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
<Link to="/money" className="text-sm text-primary hover:underline flex items-center gap-1">
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" /></h2>
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
{payroll && payroll.payslipCount > 0 ? (
<div className="space-y-4">
<div className="space-y-3">
{/* 工资构成 */}
<div>
<div className="text-sm font-medium text-gray-600 mb-2"></div>
<div className="text-xs font-medium text-gray-600 mb-1.5"></div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{payrollItems.map((item) => {
const Icon = item.icon
@@ -223,7 +223,7 @@ export default function Dashboard() {
<Icon className={`w-4 h-4 ${item.color}`} />
<span className="text-xs text-gray-500">{item.label}</span>
</div>
<span className={`text-sm font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
<span className={`text-xs font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
</div>
)
})}
@@ -233,17 +233,17 @@ export default function Dashboard() {
{/* 应发合计 */}
<div className="flex items-center justify-between border-t border-b py-2">
<span className="font-medium"></span>
<span className="text-lg font-bold text-primary">{fmt(payroll.totalPay)}</span>
<span className="text-base font-bold text-primary">{fmt(payroll.totalPay)}</span>
</div>
{/* 扣减项 */}
<div>
<div className="text-sm font-medium text-gray-600 mb-2"></div>
<div className="text-xs font-medium text-gray-600 mb-1.5"></div>
<div className="grid grid-cols-3 gap-2">
{deductionItems.map((item) => (
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-red-50">
<span className="text-xs text-gray-500">{item.label}</span>
<span className="text-sm font-medium text-danger">{fmt(item.value)}</span>
<span className="text-xs font-medium text-danger">{fmt(item.value)}</span>
</div>
))}
</div>
@@ -252,40 +252,40 @@ export default function Dashboard() {
{/* 员工实发 */}
<div className="flex items-center justify-between py-2">
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" /></span>
<span className="text-lg font-bold text-safe">{fmt(payroll.empNetPay)}</span>
<span className="text-base font-bold text-safe">{fmt(payroll.empNetPay)}</span>
</div>
{/* 企业成本 */}
<div className="border-t pt-3 space-y-2">
<div className="text-sm font-medium text-gray-600 mb-1"></div>
<div className="border-t pt-2 space-y-2">
<div className="text-xs font-medium text-gray-600 mb-1"></div>
<div className="grid grid-cols-3 gap-2">
<div className="flex items-center justify-between p-2 rounded-md bg-blue-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" /></span>
<span className="text-sm font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
<span className="text-xs font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-purple-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" /></span>
<span className="text-sm font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
<span className="text-xs font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-green-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Receipt className="w-3 h-3" /></span>
<span className="text-sm font-medium text-green-700">{fmt(payroll.totalPay)}</span>
<span className="text-xs font-medium text-green-700">{fmt(payroll.totalPay)}</span>
</div>
</div>
{payroll.severancePay > 0 && (
<div className="flex items-center justify-between p-2 rounded-md bg-orange-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><DollarSign className="w-3 h-3" /></span>
<span className="text-sm font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
<span className="text-xs font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
</div>
)}
<div className="flex items-center justify-between py-2">
<span className="font-medium flex items-center gap-2"><DollarSign className="w-4 h-4 text-danger" /></span>
<span className="text-lg font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
<span className="text-base font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
</div>
</div>
{/* 工资条确认状态 */}
<div className="flex items-center gap-4 text-sm border-t pt-3">
<div className="flex items-center gap-3 text-xs border-t pt-2">
<span className="text-gray-500"></span>
<span className="text-safe"> {payroll.confirmedPayslips}</span>
<span className="text-warning"> {payroll.unconfirmedPayslips}</span>
@@ -300,12 +300,12 @@ export default function Dashboard() {
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-4">
<div className="space-y-3">
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-sm text-gray-400">{filteredTodos.length} </span>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-xs text-gray-400">{filteredTodos.length} </span>
</div>
{filteredTodos.length === 0 ? (
@@ -317,12 +317,12 @@ export default function Dashboard() {
{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"
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<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-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>
</div>
</Link>
@@ -354,20 +354,20 @@ export default function Dashboard() {
{/* 已办事项 */}
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><CheckSquare className="w-5 h-5 text-safe" /></h2>
<span className="text-sm text-gray-400">{data.resolvedTodos.length} </span>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" /></h2>
<span className="text-xs text-gray-400">{data.resolvedTodos.length} </span>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
{data.resolvedTodos.map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50"
className="flex items-center justify-between px-2.5 py-2 rounded-md bg-gray-50"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<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-600 line-through">{todo.title}</span>
<span className="text-xs text-gray-400">{todo.description}</span>
</div>
</Link>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -40,8 +40,8 @@ export default function Settings() {
]
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold"></h1>
<div className="space-y-3">
<h1 className="text-xs font-medium"></h1>
<div className="flex gap-1 border-b">
{sections.map((s) => {
@@ -50,7 +50,7 @@ export default function Settings() {
<button
key={s.key}
onClick={() => setActiveSection(s.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
@@ -82,7 +82,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
return (
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4 max-w-md">
<div className="space-y-3">
<div>
<Label></Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="企业名称" />
@@ -126,7 +126,7 @@ function UserSettings({ usersData }: { usersData: any }) {
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 px-3 font-medium"></th>
@@ -176,8 +176,8 @@ function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void })
return (
<Modal open={open} onClose={onClose} title="添加用户">
<div className="space-y-4">
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
<div className="space-y-3">
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
@@ -225,17 +225,17 @@ function PlanSettings({ orgData }: { orgData: any }) {
<Card key={p.key}>
<div className={`px-4 py-3 rounded-t-lg ${plan === p.key ? 'bg-primary text-white' : 'bg-gray-50'}`}>
<div className="font-medium">{p.label}</div>
<div className={`text-lg font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
<div className={`text-base font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
</div>
<div className="p-4 space-y-2">
{p.features.map((f, i) => (
<div key={i} className="text-sm text-gray-600 flex items-center gap-2">
<div key={i} className="text-xs text-gray-600 flex items-center gap-2">
<span className="text-safe"></span> {f}
</div>
))}
<div className="pt-2">
{plan === p.key ? (
<div className="text-sm text-center text-primary font-medium"></div>
<div className="text-xs text-center text-primary font-medium"></div>
) : (
<Button variant="secondary" className="w-full" size="sm"></Button>
)}
@@ -288,12 +288,12 @@ function NotificationSettings() {
const logs = logsData?.items || []
return (
<div className="space-y-4">
<div className="space-y-3">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4 max-w-md">
<div className="space-y-3">
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-xs"></span>
<input type="checkbox" checked={form.contractExpiry ?? true} onChange={(e) => setForm({ ...form, contractExpiry: e.target.checked })} />
</label>
<div>
@@ -301,19 +301,19 @@ function NotificationSettings() {
<Input type="number" value={form.expiryDays ?? 30} onChange={(e) => setForm({ ...form, expiryDays: Number(e.target.value) })} />
</div>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-xs"></span>
<input type="checkbox" checked={form.contractUnsigned ?? true} onChange={(e) => setForm({ ...form, contractUnsigned: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-xs"></span>
<input type="checkbox" checked={form.overtimeAlert ?? true} onChange={(e) => setForm({ ...form, overtimeAlert: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-xs"></span>
<input type="checkbox" checked={form.payslipReady ?? true} onChange={(e) => setForm({ ...form, payslipReady: e.target.checked })} />
</label>
<div className="border-t pt-3 space-y-3">
<div className="text-sm font-medium"></div>
<div className="text-xs font-medium"></div>
<div className="text-xs text-gray-400"></div>
<div className="grid grid-cols-2 gap-3">
<div>
@@ -339,7 +339,7 @@ function NotificationSettings() {
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
</div>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-xs"></span>
<input type="checkbox" checked={form.emailNotify ?? false} onChange={(e) => setForm({ ...form, emailNotify: e.target.checked })} />
</label>
{form.emailNotify && (
@@ -362,12 +362,12 @@ function NotificationSettings() {
</Button>
</div>
{checkResult && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm mb-3">{checkResult}</div>
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs mb-3">{checkResult}</div>
)}
{logs.length > 0 ? (
<div className="space-y-2">
{logs.map((log: any) => (
<div key={log.id} className="text-sm border-b last:border-0 py-2">
<div key={log.id} className="text-xs border-b last:border-0 py-2">
<div className="font-medium">{log.title}</div>
<div className="text-gray-500 text-xs mt-0.5">{log.content}</div>
<div className="text-gray-400 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
@@ -375,7 +375,7 @@ function NotificationSettings() {
))}
</div>
) : (
<div className="text-gray-400 text-sm text-center py-4"></div>
<div className="text-gray-400 text-xs text-center py-4"></div>
)}
</Card>
</div>
+39 -39
View File
@@ -275,8 +275,8 @@ export default function Termination() {
}
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold"></h1>
<div className="space-y-3">
<h1 className="text-xs font-medium"></h1>
{/* 进度条 */}
<div className="flex items-center gap-1">
@@ -289,11 +289,11 @@ export default function Termination() {
</div>
<Card>
<div className="mb-2 text-sm text-gray-500">Step {step + 1}/5{STEPS[step]}</div>
<div className="mb-2 text-xs text-gray-500">Step {step + 1}/5{STEPS[step]}</div>
{/* Step 1: 选择员工 */}
{step === 0 && (
<div className="space-y-4">
<div className="space-y-3">
<div>
<Label></Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
@@ -304,7 +304,7 @@ export default function Termination() {
</Select>
</div>
{selectedEmployee && (
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="text-xs 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>¥{fmt(selectedEmployee.monthlySalary)}</div>
@@ -330,11 +330,11 @@ export default function Termination() {
)}
{profile && suggestions.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium">📋 </div>
<div className="text-xs font-medium">📋 </div>
{suggestions.map((s, i) => (
<div
key={i}
className={`px-3 py-2 rounded-md text-sm ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
className={`px-3 py-2 rounded-md text-xs ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
>
<div className="font-medium">{s.label}</div>
<div className="text-xs mt-0.5">{s.why}</div>
@@ -343,17 +343,17 @@ export default function Termination() {
</div>
)}
{employeeId && !profile && (
<div className="text-sm text-gray-400">...</div>
<div className="text-xs text-gray-400">...</div>
)}
</div>
)}
{/* Step 2: 解聘方式 */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-3">
{suggestions.length > 0 && (
<div className="bg-blue-50 rounded-md p-3 space-y-1">
<div className="text-sm font-medium text-blue-700">💡 </div>
<div className="text-xs font-medium text-blue-700">💡 </div>
{suggestions.filter((s) => s.reason).map((s, i) => (
<div key={i} className="text-xs text-blue-600">
{s.label}{s.why}
@@ -371,7 +371,7 @@ export default function Termination() {
>
<input type="radio" name="reason" value={r.value} checked={reason === r.value} onChange={(e) => setReason(e.target.value)} className="mt-0.5" />
<div className="flex-1">
<div className="text-sm flex items-center gap-2">
<div className="text-xs flex items-center gap-2">
{r.label}
{suggested && <span className="text-xs text-primary font-medium"></span>}
</div>
@@ -392,12 +392,12 @@ export default function Termination() {
{riskAssessment && riskAssessment.warnings.length > 0 && (
<div className="space-y-2">
{riskAssessment.warnings.map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
{w}
</div>
))}
<label className="flex items-center gap-2 text-sm px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
<label className="flex items-center gap-2 text-xs px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
<input type="checkbox" checked={acknowledgeRisk} onChange={(e) => setAcknowledgeRisk(e.target.checked)} />
</label>
@@ -416,7 +416,7 @@ export default function Termination() {
checked={checklist[item.key] || false}
onChange={(e) => setChecklist({ ...checklist, [item.key]: e.target.checked })}
/>
<span className="text-sm">{item.label}</span>
<span className="text-xs">{item.label}</span>
</label>
))}
</div>
@@ -424,15 +424,15 @@ export default function Termination() {
{/* Step 4: 费用结算 */}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-3">
<div>
<Label></Label>
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
</div>
{costResult && (
<div className="space-y-4">
<div className="space-y-3">
{/* 员工概况 */}
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="text-xs 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>¥{fmt(costResult.wage)}/</div>
@@ -443,7 +443,7 @@ export default function Termination() {
{/* 经济补偿金 / 赔偿金 */}
{costResult.noComp ? (
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-sm">
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
</div>
) : (
@@ -452,22 +452,22 @@ export default function Termination() {
<Calculator className="w-4 h-4" />
{costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
</div>
<div className="text-sm text-gray-500">{costResult.cappedMonths}</div>
<div className="text-sm text-gray-500">¥{fmt(costResult.cappedWage)}/</div>
<div className="text-xs text-gray-500">{costResult.cappedMonths}</div>
<div className="text-xs text-gray-500">¥{fmt(costResult.cappedWage)}/</div>
{costResult.isIllegal && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></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'}`}>
<span className={`text-base font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
¥{fmt(costResult.severancePay)}
</span>
</div>
{costResult.noticePay > 0 && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span>¥{fmt(costResult.noticePay)}</span>
</div>
@@ -491,12 +491,12 @@ export default function Termination() {
<AlertTriangle className="w-4 h-4" />
</div>
<div className="text-sm text-gray-500">{costResult.doubleStartDate}</div>
<div className="text-sm text-gray-500">{costResult.doubleEndDate}</div>
<div className="text-sm text-gray-500">{costResult.doubleMonths}</div>
<div className="text-xs text-gray-500">{costResult.doubleStartDate}</div>
<div className="text-xs text-gray-500">{costResult.doubleEndDate}</div>
<div className="text-xs 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">¥{fmt(costResult.doublePay)}</span>
<span className="text-base font-bold text-warning">¥{fmt(costResult.doublePay)}</span>
</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">
@@ -510,12 +510,12 @@ 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">¥{fmt(costResult.grandTotal)}</span>
<span className="text-lg font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
</div>
</div>
{!costResult.noComp && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>116116</span>
</div>
@@ -527,18 +527,18 @@ export default function Termination() {
{/* Step 5: 解聘材料 */}
{step === 4 && (
<div className="space-y-4">
<div className="space-y-3">
{saveMutation.isError ? (
<div className="text-center py-8">
<AlertTriangle className="w-12 h-12 text-danger mx-auto" />
<div className="text-danger font-medium mt-2"></div>
<div className="text-sm text-gray-500">{(saveMutation.error as any)?.response?.data?.error?.message || '请稍后重试'}</div>
<div className="text-xs text-gray-500">{(saveMutation.error as any)?.response?.data?.error?.message || '请稍后重试'}</div>
<Button onClick={() => setStep(3)} className="mt-4"></Button>
</div>
) : saveMutation.isPending ? (
<div className="text-center py-8 text-gray-400">...</div>
) : (
<div className="space-y-4">
<div className="space-y-3">
{/* 成功提示 */}
<div className="flex items-center gap-2 text-safe">
<Check className="w-5 h-5" />
@@ -556,11 +556,11 @@ export default function Termination() {
</div>
{/* 1. 解聘通知书 */}
<div className="border rounded-lg p-6 space-y-4 print:shadow-none">
<div className="border rounded-lg p-6 space-y-3 print:shadow-none">
<div className="text-center">
<h2 className="text-lg font-bold"></h2>
<h2 className="text-base font-bold"></h2>
</div>
<div className="text-sm text-gray-700 space-y-3">
<div className="text-xs text-gray-700 space-y-3">
<p><strong>{selectedEmployee?.name}</strong> /</p>
<p>
<strong>{selectedEmployee?.hireDate?.toString().slice(0, 10)}</strong> {selectedEmployee?.department}
@@ -600,7 +600,7 @@ export default function Termination() {
{costResult && (
<div className="border rounded-lg p-4 space-y-2">
<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="text-xs 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>¥{fmt(costResult.wage)}/</span></div>
{costResult.capped && <div className="text-warning"> 312</div>}
@@ -623,7 +623,7 @@ export default function Termination() {
{/* 3. 合规检查清单 */}
<div className="border rounded-lg p-4 space-y-2">
<h3 className="font-medium flex items-center gap-2"><Shield className="w-4 h-4" /></h3>
<div className="text-sm space-y-1">
<div className="text-xs space-y-1">
{checklistItems?.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<span className={checklist[item.key] ? 'text-safe' : 'text-danger'}>
@@ -661,7 +661,7 @@ export default function Termination() {
}, {})
return Object.entries(grouped).map(([category, items]) => (
<div key={category} className="space-y-1">
<div className="text-sm font-medium text-gray-700">{category as string}</div>
<div className="text-xs font-medium text-gray-700">{category as string}</div>
{(items as any[]).map((e: any, i: number) => (
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
<div className="flex items-center gap-2">
@@ -677,7 +677,7 @@ export default function Termination() {
})()}
</>
) : (
<div className="text-sm text-gray-400">...</div>
<div className="text-xs text-gray-400">...</div>
)}
</div>
</div>
@@ -48,7 +48,7 @@ export default function ContractConfirm() {
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-sm w-full text-center">
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
<h1 className="text-lg font-semibold mb-2"></h1>
<h1 className="text-sm font-semibold mb-2"></h1>
<p className="text-sm text-gray-500"> IP </p>
</div>
</div>
@@ -60,7 +60,7 @@ export default function ContractConfirm() {
<div className="max-w-md mx-auto">
<div className="flex items-center gap-2 mb-6">
<PenTool className="w-6 h-6 text-primary" />
<h1 className="text-lg font-semibold"></h1>
<h1 className="text-sm font-semibold"></h1>
</div>
{loading ? (
@@ -74,7 +74,7 @@ export default function ContractConfirm() {
</Card>
) : data ? (
<Card>
<div className="space-y-4">
<div className="space-y-3">
<div className="text-sm text-gray-600">
{data.orgName} {data.employeeName}
</div>
+2 -2
View File
@@ -37,7 +37,7 @@ export default function MyContract() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<FileText className="w-6 h-6 text-primary" />
<h1 className="text-lg font-semibold"></h1>
<h1 className="text-sm font-semibold"></h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{employee.name}</span>
@@ -51,7 +51,7 @@ export default function MyContract() {
) : !contract ? (
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
) : (
<div className="space-y-4">
<div className="space-y-3">
{/* 到期提醒 */}
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
+3 -3
View File
@@ -53,7 +53,7 @@ export default function Onboarding() {
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-sm w-full text-center">
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
<h1 className="text-lg font-semibold mb-2"></h1>
<h1 className="text-sm font-semibold mb-2"></h1>
<p className="text-sm text-gray-500">HR </p>
</div>
</div>
@@ -65,7 +65,7 @@ export default function Onboarding() {
<div className="max-w-md mx-auto">
<div className="flex items-center gap-2 mb-6">
<ClipboardList className="w-6 h-6 text-primary" />
<h1 className="text-lg font-semibold"></h1>
<h1 className="text-sm font-semibold"></h1>
</div>
{orgName && (
@@ -79,7 +79,7 @@ export default function Onboarding() {
)}
<Card>
<div className="space-y-4">
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="请输入姓名" />
+2 -2
View File
@@ -42,7 +42,7 @@ export default function Payslip() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<DollarSign className="w-6 h-6 text-primary" />
<h1 className="text-lg font-semibold"></h1>
<h1 className="text-sm font-semibold"></h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{employee.name}</span>
@@ -93,7 +93,7 @@ export default function Payslip() {
<div className="border-t pt-3">
<div className="flex justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
<span className="text-base font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
</div>
</div>
+3 -3
View File
@@ -64,7 +64,7 @@ export default function PortalLogin() {
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"> </span>
<span className="text-base font-bold"> </span>
</div>
<div className="card">
@@ -82,7 +82,7 @@ export default function PortalLogin() {
{error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
{mode === 'password' ? (
<div className="space-y-4">
<div className="space-y-3">
<div>
<Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
@@ -96,7 +96,7 @@ export default function PortalLogin() {
</Button>
</div>
) : (
<div className="space-y-4">
<div className="space-y-3">
<div>
<Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
+1 -1
View File
@@ -15,7 +15,7 @@ export default {
surface: '#F8FAFC',
},
maxWidth: {
content: '960px',
content: 'none',
},
},
},