From f74b2808a3a1a795b531593bc14cfd16963f6879 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 25 Jul 2026 22:40:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9F=8E=E5=B8=82=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=AE=8C=E5=96=84=E5=8F=8A=E8=B7=A8=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 城市变更使用CITY_CHANGE类型替代ADJUST,月度办理显示减员/新增(城市变更) - 在保人员查询排除本月已关闭记录(gte→gt)和本月新增记录(lte→lt) - 社保/公积金减员查询包含CITY_CHANGE类型 - 缴纳记录表格添加城市列显示 - 后端profile API从快照提取city字段 - 修复旧快照缺失city字段的数据 - 修复旧记录changeType为CITY_CHANGE,endMonth与新记录startMonth一致 - 城市变更必填原因,写入备注和审计日志 - 员工详情页添加变更历史Tab - 移除薪酬社保Tab下重复的参保城市变更子Tab - 全局修复跨页面mutation缓存刷新:调薪/调部门/离职/重新入职/批量续签/批量解聘/社保公积金调基/月度办理/撤销解聘均刷新roster-profile --- backend/prisma/schema.prisma | 19 + backend/prisma/seed-wufang.ts | 111 +- backend/src/routes/roster.routes.ts | 36 + backend/src/routes/social.routes.ts | 441 ++++++-- backend/src/services/contract.service.ts | 94 ++ frontend/src/pages/Contracts.tsx | 1 + frontend/src/pages/Roster.tsx | 1171 +++++++++++++++++++--- frontend/src/pages/SocialInsurance.tsx | 494 ++++++--- frontend/src/pages/Termination.tsx | 1 + 9 files changed, 1988 insertions(+), 380 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 69afd5f..04bef4e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -158,6 +158,7 @@ model Organization { trainingRecords TrainingRecord[] performanceRecords PerformanceRecord[] retirementPolicies RetirementPolicy[] + socialMonthlyProcesses SocialMonthlyProcess[] } model User { @@ -739,6 +740,24 @@ model EmployeeHousingFundRecord { @@index([employeeId, startMonth, endMonth]) } +/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照) +model SocialMonthlyProcess { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + month String // 办理月份 YYYY-MM + type String // SOCIAL=社保, HOUSING=公积金 + status String @default("COMPLETED") // COMPLETED=已办理 + snapshot Json // 办理时的数据快照(增减员+在保人员+缴费明细) + processedBy String + processedAt DateTime @default(now()) + createdBy String + createdAt DateTime @default(now()) + + @@unique([orgId, month, type]) + @@index([orgId, month]) +} + model EmployeeDepartmentRecord { id String @id @default(cuid()) orgId String diff --git a/backend/prisma/seed-wufang.ts b/backend/prisma/seed-wufang.ts index 19ba91c..40245ab 100644 --- a/backend/prisma/seed-wufang.ts +++ b/backend/prisma/seed-wufang.ts @@ -1,15 +1,18 @@ import prisma from '../src/lib/prisma' -const EID = 'cmrx61v6d001oqqcwb2pu2tih' -const ORGID = 'cmrx61v3l0000qqcwo3dr3h95' -const UID = 'cmrx61v5u0002qqcwqf4vlyth' +const EID = 'cmry97xbv001qtrrpuo12ozy7' +const ORGID = 'cmry97x7l0000trrp5f9jgng5' +const UID = 'cmry97xa10002trrp7peorkvh' 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 }, + { month: '2025-12', wh: 10, weh: 4, hh: 0, wp: 900, wep: 600, hp: 0, pay: 1500 }, + { month: '2026-03', wh: 8, weh: 8, hh: 0, wp: 600, wep: 1200, hp: 0, pay: 1800 }, + { month: '2026-06', wh: 6, weh: 0, hh: 0, wp: 450, wep: 0, hp: 0, pay: 450 }, ] for (const o of otMonths) { const existing = await prisma.overtimeRecord.findUnique({ where: { employeeId_month: { employeeId: EID, month: o.month } } }) @@ -19,10 +22,11 @@ async function main() { } console.log('加班记录: 完成') - // 违纪记录 + // 违纪记录(补充历史记录,保留已有的 2026-07-24 记录) 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: '王强' }, + { violationDate: new Date('2026-03-10'), violationType: 'INSUBORDINATION', description: '不服从主管工作安排,拒绝参加客户会议', severity: 'SERIOUS', action: 'WRITTEN_WARNING', actionDetail: '书面警告并记入档案', employeeAck: true, ackDate: new Date('2026-03-11'), ackMethod: 'SIGN', witness: '赵敏' }, ] for (const d of discRecords) { const existing = await prisma.disciplinaryRecord.findFirst({ where: { employeeId: EID, violationDate: d.violationDate } }) @@ -32,23 +36,52 @@ async function main() { } console.log('违纪记录: 完成') - // 考勤记录 - 最近10个工作日 + // 考勤记录 - 2026年6-7月最近20个工作日 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 }, + { date: '2026-06-01', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-02', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-03', status: 'LATE', late: 15, early: 0, ot: 0 }, + { date: '2026-06-04', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-05', status: 'NORMAL', late: 0, early: 0, ot: 2 }, + { date: '2026-06-08', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-09', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-10', status: 'LEAVE', late: 0, early: 0, ot: 0, remark: '事假' }, + { date: '2026-06-11', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-12', status: 'NORMAL', late: 0, early: 0, ot: 3 }, + { date: '2026-06-15', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-16', status: 'LATE', late: 30, early: 0, ot: 0 }, + { date: '2026-06-17', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-18', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-19', status: 'EARLY_LEAVE', late: 0, early: 45, ot: 0 }, + { date: '2026-06-22', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-23', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-24', status: 'BUSINESS_TRIP', late: 0, early: 0, ot: 0, remark: '上海客户拜访' }, + { date: '2026-06-25', status: 'BUSINESS_TRIP', late: 0, early: 0, ot: 0, remark: '上海客户拜访' }, + { date: '2026-06-26', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-29', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-06-30', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-01', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-02', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-03', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-06', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-07', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-08', status: 'LATE', late: 20, early: 0, ot: 0 }, + { date: '2026-07-09', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-10', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-13', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-14', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-15', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-16', status: 'LATE', late: 25, early: 0, ot: 0 }, + { date: '2026-07-17', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-20', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-21', status: 'NORMAL', late: 0, early: 0, ot: 0 }, + { date: '2026-07-22', status: 'EARLY_LEAVE', late: 0, early: 30, ot: 0 }, + { date: '2026-07-23', status: 'NORMAL', late: 0, early: 0, ot: 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 } }) + await prisma.attendanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, date: new Date(a.date), checkInTime: a.status === 'LEAVE' ? null : '09:00', checkOutTime: a.status === 'LEAVE' ? null : '18:00', status: a.status, lateMinutes: a.late, earlyMinutes: a.early, workHours: a.status === 'LEAVE' ? 0 : 8, overtimeHours: a.ot || 0, remark: a.remark || null } }) } } console.log('考勤记录: 完成') @@ -57,7 +90,9 @@ async function main() { 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: '待员工签收确认' }, + { trainingDate: new Date('2025-09-10'), topic: '《数据安全管理制度》培训', content: '客户数据保护规范、信息安全操作规程、违规处罚条例', trainer: '赵敏', duration: 2, ackStatus: 'SIGNED', ackDate: new Date('2025-09-10') }, + { trainingDate: new Date('2026-01-10'), topic: '2026年度规章制度更新培训', content: '新版考勤制度、绩效考核办法、安全生产规范', trainer: '赵敏', duration: 3, ackStatus: 'SIGNED', ackDate: new Date('2026-01-10') }, + { trainingDate: new Date('2026-04-15'), topic: '销售合规与反商业贿赂培训', content: '反商业贿赂法规、客户招待标准、合规销售流程', trainer: '王强', duration: 3, ackStatus: 'PENDING', remark: '待员工签收确认' }, ] for (const t of trainings) { const existing = await prisma.trainingRecord.findFirst({ where: { employeeId: EID, trainingDate: t.trainingDate } }) @@ -67,12 +102,14 @@ async function main() { } 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-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: '王强' }, + { 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: true, ackDate: new Date('2026-01-12'), reviewer: '王强' }, + { period: '2026-Q1', score: 72, grade: 'B', result: 'QUALIFIED', summary: '一季度业绩基本达标,大客户维护稳定,新签客户2家', improvementPlan: '', employeeAck: true, ackDate: new Date('2026-04-08'), reviewer: '王强' }, + { period: '2026-Q2', score: 65, grade: 'C', result: 'NEED_IMPROVE', summary: '二季度业绩下滑明显,客户流失1家,不服从管理记录1次', improvementPlan: '加强客户维护培训,调整销售目标考核方式', employeeAck: false, reviewer: '王强' }, ] for (const p of performances) { const existing = await prisma.performanceRecord.findUnique({ where: { employeeId_period: { employeeId: EID, period: p.period } } }) @@ -88,6 +125,8 @@ async function main() { { 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 }, + { fileName: '吴芳学位证书.jpg', fileType: 'EDUCATION', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 76800 }, + { fileName: '吴芳离职证明(前单位).pdf', fileType: 'OTHER', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 153600 }, ] for (const a of attachments) { const existing = await prisma.employeeAttachment.findFirst({ where: { employeeId: EID, fileName: a.fileName } }) @@ -97,10 +136,32 @@ async function main() { } console.log('附件: 完成') + // 社保缴费记录(入职) + const hireMonth = '2025-02' + const existingSocial = await prisma.employeeSocialInsRecord.findFirst({ where: { employeeId: EID, startMonth: hireMonth } }) + if (!existingSocial) { + await prisma.employeeSocialInsRecord.create({ + data: { orgId: ORGID, employeeId: EID, startMonth: hireMonth, endMonth: null, base: 9000, changeType: 'ONBOARDING', createdBy: UID, city: '北京' }, + }) + } + // 公积金缴费记录(入职) + const existingHousing = await prisma.employeeHousingFundRecord.findFirst({ where: { employeeId: EID, startMonth: hireMonth } }) + if (!existingHousing) { + await prisma.employeeHousingFundRecord.create({ + data: { orgId: ORGID, employeeId: EID, startMonth: hireMonth, endMonth: null, base: 9000, changeType: 'ONBOARDING', createdBy: UID, city: '北京' }, + }) + } + // 更新 Employee 便捷字段 + await prisma.employee.update({ + where: { id: EID }, + data: { city: '北京', socialInsStartMonth: hireMonth, housingFundStartMonth: hireMonth }, + }) + 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 } + include: { contracts: true, payslips: true, overtimeRecords: true, disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, terminations: true, attachments: true, socialInsRecords: true, housingFundRecords: true } }) if (emp) { console.log('--- 吴芳完整档案数据统计 ---') @@ -111,8 +172,10 @@ async function main() { 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('terminations:', emp.terminations.length, JSON.stringify(emp.terminations.map(t => ({ status: t.status, type: t.type })))) console.log('attachments:', emp.attachments.length) + console.log('socialInsRecords:', (emp as any).socialInsRecords?.length) + console.log('housingFundRecords:', (emp as any).housingFundRecords?.length) } await prisma.$disconnect() } diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 7961ae3..627e7c5 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -172,11 +172,46 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) = performanceRecords: { orderBy: { period: 'desc' } }, terminations: { orderBy: { createdAt: 'desc' } }, attachments: true, + socialInsRecords: { orderBy: { startMonth: 'desc' } }, + housingFundRecords: { orderBy: { startMonth: 'desc' } }, + salaryChanges: { orderBy: { effectiveDate: 'desc' } }, + departmentRecords: { orderBy: { effectiveMonth: 'desc' } }, }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } + + // 查询该员工相关的月度办理记录(从快照中筛选该员工) + const allProcesses = await prisma.socialMonthlyProcess.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: { month: 'desc' }, + }) + const employeeId = req.params.id + const monthlyProcessRecords: any[] = [] + for (const p of allProcesses) { + const snap = p.snapshot as any + // 从增减员快照中筛选 + const changes = snap.changes + const active = snap.active + const type = p.type + let found = false + let recordData: any = { month: p.month, type, processedAt: p.processedAt, status: p.status } + if (changes?.additions) { + const item = changes.additions.find((a: any) => a.employeeId === employeeId) + if (item) { recordData.changeType = '新增'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true } + } + if (!found && changes?.reductions) { + const item = changes.reductions.find((a: any) => a.employeeId === employeeId) + if (item) { recordData.changeType = '减少'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true } + } + if (!found && active?.items) { + const item = active.items.find((a: any) => a.employeeId === employeeId) + if (item) { recordData.changeType = '正常在保'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true } + } + if (found) monthlyProcessRecords.push(recordData) + } + const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee const today = new Date() today.setHours(0, 0, 0, 0) @@ -189,6 +224,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) = monthlySalary: safeDecrypt(monthlySalary), bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null, idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null, + monthlyProcessRecords, }, }) } catch (err) { diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index a93dba9..9044784 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -787,6 +787,57 @@ router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res // ========== 月度增减员 ========== +/** 根据基数和社保配置计算各项企业/个人缴费明细 */ +function calcSocialDetail(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const items = [ + { name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 }, + { name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: actualBase * config.medicalOrg / 100, empAmount: actualBase * config.medicalEmp / 100 }, + { name: '失业', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: actualBase * config.unemploymentOrg / 100, empAmount: actualBase * config.unemploymentEmp / 100 }, + { name: '工伤', orgRate: config.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 }, + { name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: actualBase * config.maternityOrg / 100, empAmount: 0 }, + ] + const totalOrg = items.reduce((s, i) => s + i.orgAmount, 0) + const totalEmp = items.reduce((s, i) => s + i.empAmount, 0) + return { actualBase, items, totalOrg, totalEmp } +} + +/** 根据基数和公积金配置计算企业/个人缴费明细 */ +function calcHousingDetail(base: number, config: any) { + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const orgAmount = actualBase * config.housingOrg / 100 + const empAmount = actualBase * config.housingEmp / 100 + return { actualBase, orgAmount, empAmount, total: orgAmount + empAmount } +} + +/** 按月份匹配社保配置版本 */ +async function getSocialConfigByMonth(orgId: string, month: string, city?: string) { + const where: any = { orgId } + if (city) where.city = city + let config = await prisma.socialInsuranceConfig.findFirst({ + where: { ...where, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (!config) { + config = await prisma.socialInsuranceConfig.findFirst({ where: { ...where, isCurrent: true } }) + } + return config +} + +/** 按月份匹配公积金配置版本 */ +async function getHousingConfigByMonth(orgId: string, month: string, city?: string) { + const where: any = { orgId } + if (city) where.city = city + let config = await prisma.housingFundConfig.findFirst({ + where: { ...where, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (!config) { + config = await prisma.housingFundConfig.findFirst({ where: { ...where, isCurrent: true } }) + } + return config +} + // 社保月度增减员 router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => { try { @@ -800,33 +851,59 @@ router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: Nex orderBy: { createdAt: 'asc' }, }) - // 减员:endMonth == month 且 changeType == TERMINATION + // 减员:endMonth == month 且 changeType 为 TERMINATION 或 CITY_CHANGE const reductions = await prisma.employeeSocialInsRecord.findMany({ - where: { orgId, endMonth: month, changeType: 'TERMINATION' }, + where: { orgId, endMonth: month, changeType: { in: ['TERMINATION', 'CITY_CHANGE'] } }, include: { employee: { select: { name: true, department: true, idCardNumber: true } } }, orderBy: { createdAt: 'asc' }, }) + // 按城市缓存配置 + const configCache = new Map() + const getConfigForCity = async (city: string) => { + if (!configCache.has(city)) { + configCache.set(city, await getSocialConfigByMonth(orgId, month, city)) + } + return configCache.get(city) + } + + const mapRecord = async (r: any) => { + const config = await getConfigForCity(r.city) + const detail = config ? calcSocialDetail(r.base, config) : null + return { + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + city: r.city, + base: r.base, + startMonth: r.startMonth, + endMonth: r.endMonth, + changeType: r.changeType, + detail: detail ? { + items: detail.items, + totalOrg: detail.totalOrg, + totalEmp: detail.totalEmp, + total: detail.totalOrg + detail.totalEmp, + } : null, + } + } + + // 按城市分组 + const allRecords = [...additions, ...reductions] + const cities = [...new Set(allRecords.map((r) => r.city))] + const configs: Record = {} + for (const c of cities) { + const cfg = await getConfigForCity(c) + if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax } + } + res.json({ success: true, data: { month, - additions: additions.map((r) => ({ - employeeId: r.employeeId, - name: r.employee.name, - department: r.employee.department, - base: r.base, - startMonth: r.startMonth, - changeType: r.changeType, - })), - reductions: reductions.map((r) => ({ - employeeId: r.employeeId, - name: r.employee.name, - department: r.employee.department, - base: r.base, - endMonth: r.endMonth, - changeType: r.changeType, - })), + configs, + additions: await Promise.all(additions.map(mapRecord)), + reductions: await Promise.all(reductions.map(mapRecord)), }, }) } catch (err) { @@ -847,31 +924,50 @@ router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, n }) const reductions = await prisma.employeeHousingFundRecord.findMany({ - where: { orgId, endMonth: month, changeType: 'TERMINATION' }, + where: { orgId, endMonth: month, changeType: { in: ['TERMINATION', 'CITY_CHANGE'] } }, include: { employee: { select: { name: true, department: true, idCardNumber: true } } }, orderBy: { createdAt: 'asc' }, }) + const configCache = new Map() + const getConfigForCity = async (city: string) => { + if (!configCache.has(city)) { + configCache.set(city, await getHousingConfigByMonth(orgId, month, city)) + } + return configCache.get(city) + } + + const mapRecord = async (r: any) => { + const config = await getConfigForCity(r.city) + const detail = config ? calcHousingDetail(r.base, config) : null + return { + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + city: r.city, + base: r.base, + startMonth: r.startMonth, + endMonth: r.endMonth, + changeType: r.changeType, + detail: detail ? { orgAmount: detail.orgAmount, empAmount: detail.empAmount, total: detail.total } : null, + } + } + + const allRecords = [...additions, ...reductions] + const cities = [...new Set(allRecords.map((r) => r.city))] + const configs: Record = {} + for (const c of cities) { + const cfg = await getConfigForCity(c) + if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax, housingOrg: cfg.housingOrg, housingEmp: cfg.housingEmp } + } + res.json({ success: true, data: { month, - additions: additions.map((r) => ({ - employeeId: r.employeeId, - name: r.employee.name, - department: r.employee.department, - base: r.base, - startMonth: r.startMonth, - changeType: r.changeType, - })), - reductions: reductions.map((r) => ({ - employeeId: r.employeeId, - name: r.employee.name, - department: r.employee.department, - base: r.base, - endMonth: r.endMonth, - changeType: r.changeType, - })), + configs, + additions: await Promise.all(additions.map(mapRecord)), + reductions: await Promise.all(reductions.map(mapRecord)), }, }) } catch (err) { @@ -890,27 +986,52 @@ router.get('/active-declaration', async (req: AuthRequest, res: Response, next: const records = await prisma.employeeSocialInsRecord.findMany({ where: { orgId, - startMonth: { lte: month }, - OR: [{ endMonth: null }, { endMonth: { gte: month } }], + startMonth: { lt: month }, + OR: [{ endMonth: null }, { endMonth: { gt: month } }], }, include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } }, orderBy: { createdAt: 'asc' }, }) + const configCache = new Map() + const getConfigForCity = async (city: string) => { + if (!configCache.has(city)) { + configCache.set(city, await getSocialConfigByMonth(orgId, month, city)) + } + return configCache.get(city) + } + + const items = await Promise.all(records.map(async (r) => { + const config = await getConfigForCity(r.city) + const detail = config ? calcSocialDetail(r.base, config) : null + return { + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + city: r.city, + base: r.base, + startMonth: r.startMonth, + endMonth: r.endMonth, + changeType: r.changeType, + detail: detail ? { + items: detail.items, + totalOrg: detail.totalOrg, + totalEmp: detail.totalEmp, + total: detail.totalOrg + detail.totalEmp, + } : null, + } + })) + + const cities = [...new Set(records.map((r) => r.city))] + const configs: Record = {} + for (const c of cities) { + const cfg = await getConfigForCity(c) + if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax } + } + res.json({ success: true, - data: { - month, - items: records.map((r) => ({ - employeeId: r.employeeId, - name: r.employee.name, - department: r.employee.department, - base: r.base, - startMonth: r.startMonth, - endMonth: r.endMonth, - changeType: r.changeType, - })), - }, + data: { month, configs, items }, }) } catch (err) { next(err) @@ -926,26 +1047,85 @@ router.get('/housing/active-declaration', async (req: AuthRequest, res: Response const records = await prisma.employeeHousingFundRecord.findMany({ where: { orgId, - startMonth: { lte: month }, - OR: [{ endMonth: null }, { endMonth: { gte: month } }], + startMonth: { lt: month }, + OR: [{ endMonth: null }, { endMonth: { gt: month } }], }, include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } }, orderBy: { createdAt: 'asc' }, }) + const configCache = new Map() + const getConfigForCity = async (city: string) => { + if (!configCache.has(city)) { + configCache.set(city, await getHousingConfigByMonth(orgId, month, city)) + } + return configCache.get(city) + } + + const items = await Promise.all(records.map(async (r) => { + const config = await getConfigForCity(r.city) + const detail = config ? calcHousingDetail(r.base, config) : null + return { + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + city: r.city, + base: r.base, + startMonth: r.startMonth, + endMonth: r.endMonth, + changeType: r.changeType, + detail: detail ? { orgAmount: detail.orgAmount, empAmount: detail.empAmount, total: detail.total } : null, + } + })) + + const cities = [...new Set(records.map((r) => r.city))] + const configs: Record = {} + for (const c of cities) { + const cfg = await getConfigForCity(c) + if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax, housingOrg: cfg.housingOrg, housingEmp: cfg.housingEmp } + } + + res.json({ + success: true, + data: { month, configs, items }, + }) + } catch (err) { + next(err) + } +}) + +// ========== 月度办理完成(保存快照) ========== + +// 列出所有已办理月份(用于办理总览) +router.get('/monthly-process/list', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const records = await prisma.socialMonthlyProcess.findMany({ + where: { orgId }, + orderBy: { month: 'desc' }, + select: { id: true, month: true, type: true, status: true, processedAt: true, processedBy: true }, + }) + res.json({ success: true, data: records }) + } catch (err) { + next(err) + } +}) + +// 查询某月办理状态 +router.get('/monthly-process/status', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const orgId = req.user!.orgId + + const records = await prisma.socialMonthlyProcess.findMany({ + where: { orgId, month }, + }) res.json({ success: true, data: { month, - items: records.map((r) => ({ - employeeId: r.employeeId, - name: r.employee.name, - department: r.employee.department, - base: r.base, - startMonth: r.startMonth, - endMonth: r.endMonth, - changeType: r.changeType, - })), + social: records.find((r) => r.type === 'SOCIAL') || null, + housing: records.find((r) => r.type === 'HOUSING') || null, }, }) } catch (err) { @@ -953,4 +1133,145 @@ router.get('/housing/active-declaration', async (req: AuthRequest, res: Response } }) +// 办理完成(保存快照) +router.post('/monthly-process/complete', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { month, type, snapshot } = req.body as { month: string; type: 'SOCIAL' | 'HOUSING'; snapshot: any } + const orgId = req.user!.orgId + + if (!month || !type || !snapshot) { + return res.status(400).json({ success: false, message: '缺少必要参数' }) + } + + const existing = await prisma.socialMonthlyProcess.findUnique({ + where: { orgId_month_type: { orgId, month, type } }, + }) + + if (existing) { + // 已存在则更新快照 + const updated = await prisma.socialMonthlyProcess.update({ + where: { id: existing.id }, + data: { snapshot, processedBy: req.user!.id, processedAt: new Date() }, + }) + return res.json({ success: true, data: updated }) + } + + const record = await prisma.socialMonthlyProcess.create({ + data: { + orgId, + month, + type, + snapshot, + processedBy: req.user!.id, + createdBy: req.user!.id, + }, + }) + res.json({ success: true, data: record }) + } catch (err) { + next(err) + } +}) + +// ========== 记录修正(直接更新 + 审计日志) ========== + +// 修正社保记录 +router.put('/records/social/:id/correct', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { city, base, startMonth, endMonth, changeType, remark } = req.body as { city?: string; base?: number; startMonth?: string; endMonth?: string; changeType?: string; remark?: string } + + const record = await prisma.employeeSocialInsRecord.findFirst({ where: { id: req.params.id, orgId } }) + if (!record) return res.status(404).json({ success: false, message: '记录不存在' }) + + const oldData = { city: record.city, base: record.base, startMonth: record.startMonth, endMonth: record.endMonth, changeType: record.changeType, remark: record.remark } + const updateData: any = {} + if (city !== undefined) updateData.city = city + if (base !== undefined) updateData.base = base + if (startMonth !== undefined) updateData.startMonth = startMonth + if (endMonth !== undefined) updateData.endMonth = endMonth || null + if (changeType !== undefined) updateData.changeType = changeType + if (remark !== undefined) updateData.remark = remark + + const updated = await prisma.employeeSocialInsRecord.update({ where: { id: req.params.id }, data: updateData }) + + // 同步员工便捷字段(如果修正的是当前在保记录) + if (!updated.endMonth) { + await prisma.employee.update({ + where: { id: record.employeeId }, + data: { + ...(city !== undefined ? { city } : {}), + ...(base !== undefined ? { socialInsBase: base } : {}), + ...(startMonth !== undefined ? { socialInsStartMonth: startMonth } : {}), + }, + }) + } + + // 写审计日志 + await prisma.auditLog.create({ + data: { + orgId, + userId: req.user!.id, + action: 'CORRECT', + entity: 'EmployeeSocialInsRecord', + entityId: req.params.id, + detail: { old: oldData, new: updateData, reason: req.body.reason || '数据修正' }, + }, + }) + + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 修正公积金记录 +router.put('/records/housing/:id/correct', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { city, base, startMonth, endMonth, changeType, remark } = req.body as { city?: string; base?: number; startMonth?: string; endMonth?: string; changeType?: string; remark?: string } + + const record = await prisma.employeeHousingFundRecord.findFirst({ where: { id: req.params.id, orgId } }) + if (!record) return res.status(404).json({ success: false, message: '记录不存在' }) + + const oldData = { city: record.city, base: record.base, startMonth: record.startMonth, endMonth: record.endMonth, changeType: record.changeType, remark: record.remark } + const updateData: any = {} + if (city !== undefined) updateData.city = city + if (base !== undefined) updateData.base = base + if (startMonth !== undefined) updateData.startMonth = startMonth + if (endMonth !== undefined) updateData.endMonth = endMonth || null + if (changeType !== undefined) updateData.changeType = changeType + if (remark !== undefined) updateData.remark = remark + + const updated = await prisma.employeeHousingFundRecord.update({ where: { id: req.params.id }, data: updateData }) + + // 同步员工便捷字段(如果修正的是当前在保记录) + if (!updated.endMonth) { + await prisma.employee.update({ + where: { id: record.employeeId }, + data: { + ...(city !== undefined ? { city } : {}), + ...(base !== undefined ? { housingFundBase: base } : {}), + ...(startMonth !== undefined ? { housingFundStartMonth: startMonth } : {}), + }, + }) + } + + // 写审计日志 + await prisma.auditLog.create({ + data: { + orgId, + userId: req.user!.id, + action: 'CORRECT', + entity: 'EmployeeHousingFundRecord', + entityId: req.params.id, + detail: { old: oldData, new: updateData, reason: req.body.reason || '数据修正' }, + }, + }) + + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index a20e499..bb02df5 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -521,6 +521,100 @@ export async function updateEmployee(orgId: string, id: string, data: any) { if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction if (data.city !== undefined) updateData.city = data.city + // 参保城市变更:关闭旧城市在保记录,创建新城市记录 + if (data.city !== undefined && data.city !== employee.city) { + const nowMonth = new Date().toISOString().slice(0, 7) + const cityChangeReason = data.cityChangeReason || '未填写原因' + const changeRemark = `城市变更:${employee.city || '未设置'} → ${data.city}(${cityChangeReason})` + // 社保:关闭旧在保记录,创建新城市记录 + const activeSocial = await prisma.employeeSocialInsRecord.findFirst({ + where: { employeeId: id, endMonth: null }, + }) + if (activeSocial) { + await prisma.employeeSocialInsRecord.update({ + where: { id: activeSocial.id }, + data: { endMonth: nowMonth, changeType: 'CITY_CHANGE', remark: changeRemark }, + }) + await prisma.employeeSocialInsRecord.create({ + data: { + orgId, + employeeId: id, + city: data.city, + startMonth: nowMonth, + endMonth: null, + base: activeSocial.base, + changeType: 'CITY_CHANGE', + remark: changeRemark, + createdBy: '', + }, + }) + } else { + // 兜底:没有在保记录也创建一条,保留变更历史 + await prisma.employeeSocialInsRecord.create({ + data: { + orgId, + employeeId: id, + city: data.city, + startMonth: nowMonth, + endMonth: null, + base: employee.socialInsBase || 0, + changeType: 'CITY_CHANGE', + remark: changeRemark, + createdBy: '', + }, + }) + } + // 公积金:同上 + const activeHousing = await prisma.employeeHousingFundRecord.findFirst({ + where: { employeeId: id, endMonth: null }, + }) + if (activeHousing) { + await prisma.employeeHousingFundRecord.update({ + where: { id: activeHousing.id }, + data: { endMonth: nowMonth, changeType: 'CITY_CHANGE', remark: changeRemark }, + }) + await prisma.employeeHousingFundRecord.create({ + data: { + orgId, + employeeId: id, + city: data.city, + startMonth: nowMonth, + endMonth: null, + base: activeHousing.base, + changeType: 'CITY_CHANGE', + remark: changeRemark, + createdBy: '', + }, + }) + } else { + // 兜底:没有在保记录也创建一条 + await prisma.employeeHousingFundRecord.create({ + data: { + orgId, + employeeId: id, + city: data.city, + startMonth: nowMonth, + endMonth: null, + base: employee.housingFundBase || 0, + changeType: 'CITY_CHANGE', + remark: changeRemark, + createdBy: '', + }, + }) + } + // 写审计日志 + await prisma.auditLog.create({ + data: { + orgId, + userId: '', + action: 'CITY_CHANGE', + entity: 'Employee', + entityId: id, + detail: { oldCity: employee.city, newCity: data.city, reason: cityChangeReason, remark: changeRemark }, + }, + }) + } + await prisma.employee.update({ where: { id }, data: updateData }) await runRiskDetection(orgId) diff --git a/frontend/src/pages/Contracts.tsx b/frontend/src/pages/Contracts.tsx index 1dc6fc9..8652e84 100644 --- a/frontend/src/pages/Contracts.tsx +++ b/frontend/src/pages/Contracts.tsx @@ -51,6 +51,7 @@ export default function Contracts() { queryClient.invalidateQueries({ queryKey: ['employees'] }) queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowAddModal(false) }, }) diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index c101f58..4a5852e 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' -import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw } from 'lucide-react' +import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from 'lucide-react' import { QRCodeSVG } from 'qrcode.react' import api from '../lib/api' import { useDebouncedValue } from '../hooks/useDebouncedValue' @@ -24,7 +24,54 @@ const terminateReasonMap: Record = { EXPIRED: '合同到期不续签', } -type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence' +type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'history' + +type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史' + +const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: string; icon: any }[] }[] = [ + { + group: '人事信息', + tabs: [ + { key: 'basic', label: '基本信息', icon: Users }, + { key: 'contract', label: '劳动合同', icon: FileText }, + ], + }, + { + group: '薪酬', + tabs: [ + { key: 'payslip', label: '薪酬社保', icon: DollarSign }, + ], + }, + { + group: '考勤绩效', + tabs: [ + { key: 'attendance', label: '考勤培训', icon: Calendar }, + { key: 'performance', label: '绩效考核', icon: TrendingUp }, + ], + }, + { + group: '风险合规', + tabs: [ + { key: 'disciplinary', label: '违纪记录', icon: AlertTriangle }, + { key: 'termination', label: '离职/解聘', icon: FileText }, + ], + }, + { + group: '变更历史', + tabs: [ + { key: 'history', label: '变更历史', icon: History }, + ], + }, +] + +const TAB_COUNT_KEYS: Record = { + contract: 'contracts', + payslip: 'payslips', + attendance: 'attendanceRecords', + disciplinary: 'disciplinaryRecords', + performance: 'performanceRecords', + termination: 'terminations', +} export default function Roster() { const queryClient = useQueryClient() @@ -74,6 +121,7 @@ export default function Roster() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowAddModal(false) }, }) @@ -91,6 +139,7 @@ export default function Roster() { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['termination-drafts'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程') setShowResignModal(false) setResignEmployee(null) @@ -102,6 +151,7 @@ export default function Roster() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) }, }) @@ -110,6 +160,7 @@ export default function Roster() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowRehireModal(false) setRehireEmployee(null) }, @@ -120,6 +171,7 @@ export default function Roster() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowSalaryModal(false) setSalaryEmployee(null) }, @@ -130,6 +182,7 @@ export default function Roster() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowDeptModal(false) setDeptEmployee(null) }, @@ -140,6 +193,7 @@ export default function Roster() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowBatchRenewModal(false) setSelectedIds(new Set()) setPreviewData(null) @@ -179,6 +233,7 @@ export default function Roster() { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['termination-drafts'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) if (success > 0) toast.success(`已创建 ${success} 个解聘草稿,请前往「解聘补偿」页面完成流程`) if (failed > 0) toast.error(`${failed} 个员工创建草稿失败`) setShowBatchTerminateModal(false) @@ -774,19 +829,27 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: ( }, }) - const tabs: { key: DetailTab; label: string; icon: any }[] = [ - { key: 'basic', label: '基本信息', icon: Users }, - { key: 'contract', label: '劳动合同', icon: FileText }, - { key: 'payslip', label: '工资条', icon: FileText }, - { key: 'overtime', label: '加班记录', icon: Calendar }, - { key: 'disciplinary', label: '违纪记录', icon: AlertTriangle }, - { key: 'attendance', label: '考勤记录', icon: Calendar }, - { key: 'training', label: '培训签收', icon: GraduationCap }, - { key: 'performance', label: '绩效考核', icon: TrendingUp }, - { key: 'termination', label: '离职/解聘记录', icon: FileText }, - { key: 'attachment', label: '附件管理', icon: Paperclip }, - { key: 'evidence', label: '仲裁证据链', icon: Scale }, - ] + // 离职员工隐藏在职才有的操作 Tab + const isActive = profile?.status === 'ACTIVE' + const HIDDEN_FOR_RESIGNED: DetailTab[] = ['attendance', 'performance'] + + const getTabCount = (key: DetailTab): number => { + const dataKey = TAB_COUNT_KEYS[key] + if (!dataKey || !profile) return 0 + const data = (profile as any)[dataKey] + if (!Array.isArray(data)) return 0 + // 薪酬社保tab合并显示工资条+缴纳记录数 + if (key === 'payslip') { + const monthly = (profile as any).monthlyProcessRecords + return data.length + (Array.isArray(monthly) ? monthly.length : 0) + } + // 考勤培训tab合并显示考勤+培训记录数 + if (key === 'attendance') { + const training = (profile as any).trainingRecords + return data.length + (Array.isArray(training) ? training.length : 0) + } + return data.length + } if (isLoading) return
加载中...
if (!profile) return
员工不存在
@@ -804,43 +867,98 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
- {tabs.map((t) => { - const Icon = t.icon - return ( - - ) - })} + {TAB_GROUPS.map((group) => ( +
+ {group.tabs + .filter((t) => isActive || !HIDDEN_FOR_RESIGNED.includes(t.key)) + .map((t) => { + const Icon = t.icon + const count = getTabCount(t.key) + return ( + + ) + })} +
+ ))}
- {tab === 'basic' && } + {tab === 'basic' && } {tab === 'contract' && } - {tab === 'payslip' && } - {tab === 'overtime' && } + {tab === 'payslip' && } {tab === 'disciplinary' && } - {tab === 'attendance' && } - {tab === 'training' && } + {tab === 'attendance' && } {tab === 'performance' && } {tab === 'termination' && } - {tab === 'attachment' && } - {tab === 'evidence' && } + {tab === 'history' && }
) } -function BasicInfo({ profile }: { profile: any }) { +function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) { const queryClient = useQueryClient() const [editing, setEditing] = useState(false) + const fileInputRef = useRef(null) + const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD') + + const addAttachmentMutation = useMutation({ + mutationFn: (data: any) => api.post('/attachments', data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }), + }) + + const deleteAttachmentMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/attachments/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }), + }) + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic'] + const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic'] + const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) + if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) { + toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式') + return + } + if (file.size > 10 * 1024 * 1024) { + toast.error('文件过大,请上传小于 10MB 的文件') + return + } + const reader = new FileReader() + reader.onload = (event) => { + const fileUrl = event.target?.result as string + addAttachmentMutation.mutate({ employeeId, fileName: file.name, fileType, fileUrl, fileSize: file.size }) + } + reader.readAsDataURL(file) + } + + const fileTypeLabels: Record = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' } + const fileTypeColors: Record = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' } + const formatSize = (bytes: number) => { + if (!bytes) return '-' + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB` + return `${(bytes / 1024 / 1024).toFixed(1)}MB` + } + const [form, setForm] = useState({ department: profile.department || '', gender: profile.gender || '男', @@ -859,6 +977,8 @@ function BasicInfo({ profile }: { profile: any }) { socialInsBase: profile.socialInsBase ?? '', housingFundBase: profile.housingFundBase ?? '', specialDeduction: profile.specialDeduction ?? 0, + city: profile.city || '', + cityChangeReason: '', }) const updateMutation = useMutation({ @@ -871,6 +991,10 @@ function BasicInfo({ profile }: { profile: any }) { }) const handleSave = () => { + if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) { + toast.error('参保城市变更必须填写变更原因') + return + } const data: any = { department: form.department, gender: form.gender, @@ -889,11 +1013,13 @@ function BasicInfo({ profile }: { profile: any }) { socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase), housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase), specialDeduction: Number(form.specialDeduction) || 0, + city: form.city || undefined, + cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined, } updateMutation.mutate(data) } - const fields = [ + const personalFields = [ { label: '姓名', value: profile.name }, { label: '部门', value: profile.department }, { label: '性别', value: profile.gender || '未填写' }, @@ -903,12 +1029,6 @@ function BasicInfo({ profile }: { profile: any }) { { label: '身份证号', value: profile.idCardNumber || '未填写' }, { label: '手机号', value: profile.phone || '未填写' }, { label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) }, - { label: '月工资', value: `¥${fmt(profile.monthlySalary)}` }, - { label: '紧急联系人', value: profile.emergencyContact || '未填写' }, - { label: '紧急联系电话', value: profile.emergencyPhone || '未填写' }, - { label: '住址', value: profile.address || '未填写' }, - { label: '开户行', value: profile.bankName || '未填写' }, - { label: '银行账号', value: profile.bankAccount || '未填写' }, { label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' }, ...(profile.retirementDaysLeft != null ? (() => { @@ -940,6 +1060,14 @@ function BasicInfo({ profile }: { profile: any }) { .reverse()[0] || '未记录' }] : []), ] + const salaryFields = [ + { label: '月工资', value: `¥${fmt(profile.monthlySalary)}` }, + { label: '紧急联系人', value: profile.emergencyContact || '未填写' }, + { label: '紧急联系电话', value: profile.emergencyPhone || '未填写' }, + { label: '住址', value: profile.address || '未填写' }, + { label: '开户行', value: profile.bankName || '未填写' }, + { label: '银行账号', value: profile.bankAccount || '未填写' }, + ] const special = [ { label: '孕期', value: profile.isPregnant }, { label: '医疗期', value: profile.isInMedicalPeriod }, @@ -961,13 +1089,29 @@ function BasicInfo({ profile }: { profile: any }) { )} {!editing ? ( -
- {fields.map((f) => ( -
- {f.label} - {f.value} +
+
+

个人信息

+
+ {personalFields.map((f) => ( +
+ {f.label} + {f.value} +
+ ))}
- ))} +
+
+

薪酬与银行

+
+ {salaryFields.map((f) => ( +
+ {f.label} + {f.value} +
+ ))} +
+
) : (
@@ -993,7 +1137,11 @@ function BasicInfo({ profile }: { profile: any }) {

薪税信息

{!editing ? ( -
+
+
+ 参保城市 + {profile.city || '未设置'} +
社保缴费基数 {profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'} @@ -1008,7 +1156,11 @@ function BasicInfo({ profile }: { profile: any }) {
) : ( -
+
+
+ + setForm({ ...form, city: e.target.value })} /> +
setForm({ ...form, socialInsBase: e.target.value })} /> @@ -1021,6 +1173,12 @@ function BasicInfo({ profile }: { profile: any }) { setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
+ {form.city !== (profile.city || '') && ( +
+ + setForm({ ...form, cityChangeReason: e.target.value })} /> +
+ )}
)}

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

@@ -1086,6 +1244,53 @@ function BasicInfo({ profile }: { profile: any }) {
)} + + {/* 附件管理 */} +
+
+

附件管理({attachments?.length || 0}个)

+ {!editing && ( +
+ + + +
+ )} +
+ {!editing && attachments?.length ? ( +
+ {attachments.map((att) => ( +
+
+ +
+
{att.fileName}
+
+ {fileTypeLabels[att.fileType] || att.fileType} + {formatSize(att.fileSize)} +
+
+
+
+ + + + + +
+
+ ))} +
+ ) : !editing ?
暂无附件
: null} +
) } @@ -1274,17 +1479,17 @@ function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; ) : contracts.map((c) => (
-
-
合同类型{typeMap[c.contractType] || c.contractType}
-
签订日期{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}
-
合同开始{c.startDate?.toString().slice(0, 10)}
-
合同结束{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}
-
合同期限{c.contractYears}年
-
试用期{c.probationMonths}个月(¥{c.probationSalary})
-
签订方式{c.signMethod === 'PAPER' ? '纸质' : '电子'}
-
续签次数{c.renewalCount}
+
+
合同类型{typeMap[c.contractType] || c.contractType}
+
签订日期{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}
+
合同开始{c.startDate?.toString().slice(0, 10)}
+
合同结束{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}
+
合同期限{c.contractYears}年
+
试用期{c.probationMonths}个月(¥{c.probationSalary})
+
签订方式{c.signMethod === 'PAPER' ? '纸质' : '电子'}
+
续签次数{c.renewalCount}
{c.signMethod === 'PAPER' && ( -
+
合同扫描件 {c.attachmentUrl ? ( @@ -1299,7 +1504,7 @@ function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; <> {c.electronicContractNo &&
电子合同编号{c.electronicContractNo}
} {c.electronicContractUrl && ( -
+
电子合同 查看电子合同 @@ -2176,103 +2381,636 @@ function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attac ) } -function PayslipInfo({ payslips }: { payslips: any[] }) { - if (!payslips?.length) return
暂无工资条记录
- const totalBase = payslips.reduce((s, p) => s + (p.baseSalary || 0), 0) - const totalOT = payslips.reduce((s, p) => s + (p.overtimePay || 0), 0) - const totalAllow = payslips.reduce((s, p) => s + (p.allowance || 0), 0) - const totalDed = payslips.reduce((s, p) => s + (p.deduction || 0), 0) - const totalPay = payslips.reduce((s, p) => s + (p.totalPay || 0), 0) +/** 薪酬社保合并组件(工资条 / 缴纳记录) */ +function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) { + const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip') + + const changeTypeMap: Record = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', CITY_CHANGE: '城市变更' } + const changeTypeColor: Record = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' } + return ( - - - - - - - - - - - - - - - {payslips.map((p) => ( - - - - - - - - - - ))} - - - - - - - - - - -
月份基本工资加班费津贴扣款应发合计确认状态
{p.month}¥{fmt(p.baseSalary)}{p.overtimePay > 0 ? `¥${fmt(p.overtimePay)}` : '-'}{p.allowance > 0 ? `¥${fmt(p.allowance)}` : '-'}{p.deduction > 0 ? `-¥${fmt(p.deduction)}` : '-'}¥{fmt(p.totalPay)} - {p.confirmedAt ? ( - 已确认 - ) : ( - 未确认 - )} -
合计¥{fmt(totalBase)}{totalOT > 0 ? `¥${fmt(totalOT)}` : '-'}{totalAllow > 0 ? `¥${fmt(totalAllow)}` : '-'}{totalDed > 0 ? `-¥${fmt(totalDed)}` : '-'}¥{fmt(totalPay)}
-
+
+
+ + +
+ + {subTab === 'payslip' && ( + <> + {!payslips?.length ? ( +
暂无工资条记录
+ ) : ( + + + + + + + + + + + + + + + {payslips.map((p) => ( + + + + + + + + + + ))} + + + + + + + + + + +
月份基本工资加班费津贴扣款应发合计确认状态
{p.month}¥{fmt(p.baseSalary)}{p.overtimePay > 0 ? `¥${fmt(p.overtimePay)}` : '-'}{p.allowance > 0 ? `¥${fmt(p.allowance)}` : '-'}{p.deduction > 0 ? `-¥${fmt(p.deduction)}` : '-'}¥{fmt(p.totalPay)} + {p.confirmedAt ? ( + 已确认 + ) : ( + 未确认 + )} +
合计¥{fmt(payslips.reduce((s, p) => s + (p.baseSalary || 0), 0))}{payslips.reduce((s, p) => s + (p.overtimePay || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.overtimePay || 0), 0))}` : '-'}{payslips.reduce((s, p) => s + (p.allowance || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.allowance || 0), 0))}` : '-'}{payslips.reduce((s, p) => s + (p.deduction || 0), 0) > 0 ? `-¥${fmt(payslips.reduce((s, p) => s + (p.deduction || 0), 0))}` : '-'}¥{fmt(payslips.reduce((s, p) => s + (p.totalPay || 0), 0))}
+
+ )} + + )} + + {subTab === 'monthly' && ( + <> + {!monthlyProcessRecords?.length ? ( +
暂无缴纳记录
+ ) : ( + + + + + + + + + + + + + + + + + + {monthlyProcessRecords.map((r, idx) => { + const d = r.detail + const isSocial = r.type === 'SOCIAL' + const orgAmt = isSocial ? d?.totalOrg : d?.orgAmount + const empAmt = isSocial ? d?.totalEmp : d?.empAmount + const total = isSocial ? d?.total : d?.total + return ( + + + + + + + + + + + + + ) + })} + +
办理月份类型城市状态缴费基数企业部分个人部分合计变动办理时间
{r.month}{isSocial ? '社保' : '公积金'}{r.city || '-'}已缴纳¥{fmt(r.base)}{orgAmt != null ? `¥${fmt(orgAmt)}` : '-'}{empAmt != null ? `¥${fmt(empAmt)}` : '-'}{total != null ? `¥${fmt(total)}` : '-'}{r.changeType}{new Date(r.processedAt).toLocaleString('zh-CN')}
+
+ )} + + )} + +
) } -function OvertimeInfo({ records }: { records: any[] }) { - if (!records?.length) return
暂无加班记录
- const totalPay = records.reduce((sum, o) => sum + (o.totalPay || 0), 0) - const totalWeekday = records.reduce((sum, o) => sum + (o.weekdayHours || 0), 0) - const totalWeekend = records.reduce((sum, o) => sum + (o.weekendHours || 0), 0) - const totalHoliday = records.reduce((sum, o) => sum + (o.holidayHours || 0), 0) +/** 参保城市变更历史组件(含修正功能) */ +function CityHistoryTab({ socialInsRecords, housingFundRecords, changeTypeMap, changeTypeColor }: { socialInsRecords: any[]; housingFundRecords: any[]; changeTypeMap: Record; changeTypeColor: Record }) { + const queryClient = useQueryClient() + const [editing, setEditing] = useState(null) + const [correctForm, setCorrectForm] = useState({ city: '', base: '', startMonth: '', endMonth: '', changeType: '', remark: '', reason: '' }) + + const correctMutation = useMutation({ + mutationFn: async (data: any) => { + const cat = editing.cat + const url = cat === '社保' + ? `/social/records/social/${editing.id}/correct` + : `/social/records/housing/${editing.id}/correct` + const res = await api.put(url, data) as any + return res.data + }, + onSuccess: () => { + toast.success('记录已修正') + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + setEditing(null) + }, + onError: () => toast.error('修正失败'), + }) + + const handleCorrect = () => { + correctMutation.mutate({ + city: correctForm.city || undefined, + base: correctForm.base ? Number(correctForm.base) : undefined, + startMonth: correctForm.startMonth || undefined, + endMonth: correctForm.endMonth || undefined, + changeType: correctForm.changeType || undefined, + remark: correctForm.remark || undefined, + reason: correctForm.reason || '数据修正', + }) + } + + const openCorrect = (r: any) => { + setEditing(r) + setCorrectForm({ + city: r.city || '', + base: String(r.base || ''), + startMonth: r.startMonth || '', + endMonth: r.endMonth || '', + changeType: r.changeType || '', + remark: r.remark || '', + reason: '', + }) + } + + const allRecords = [ + ...(socialInsRecords || []).map((r: any) => ({ ...r, cat: '社保' })), + ...(housingFundRecords || []).map((r: any) => ({ ...r, cat: '公积金' })), + ].sort((a, b) => b.startMonth.localeCompare(a.startMonth)) + + if (!allRecords.length) return
暂无参保记录
+ return ( - - - - - - - - - - - - - {records.map((o) => ( - - - - - - + <> + +
参保城市变更历史(社保 + 公积金记录按时间倒序,点击「修正」可直接修改错误数据并记录审计日志)
+
月份工作日(h)休息日(h)节假日(h)加班费
{o.month}{o.weekdayHours || '-'}{o.weekendHours || '-'}{o.holidayHours || '-'}¥{fmt(o.totalPay)}
+ + + + + + + + + + + + + {allRecords.map((r, idx) => ( + + + + + + + + + + + ))} + +
开始年月截止年月类型参保城市缴费基数变动类型备注操作
{r.startMonth}{r.endMonth || '至今'}{r.cat}{r.city || '-'}¥{fmt(r.base)}{changeTypeMap[r.changeType] || r.changeType}{r.remark || '-'}
+
+ + {editing && ( + setEditing(null)} title={`修正${editing.cat}记录`}> +
+
+ 此操作将直接修改记录并写入审计日志(记录修改前后的值和修正原因),不会创建新记录。 +
+
+
+ + setCorrectForm({ ...correctForm, city: e.target.value })} placeholder="如 北京" /> +
+
+ + setCorrectForm({ ...correctForm, base: e.target.value })} /> +
+
+ + setCorrectForm({ ...correctForm, startMonth: e.target.value })} /> +
+
+ + setCorrectForm({ ...correctForm, endMonth: e.target.value })} placeholder="留空=至今" /> +
+
+ + +
+
+ + setCorrectForm({ ...correctForm, remark: e.target.value })} /> +
+
+
+ + setCorrectForm({ ...correctForm, reason: e.target.value })} placeholder="如:城市录入错误" /> +
+
+ + +
+
+
+ )} + + ) +} + +/** 变更历史Tab:分组显示各类变更记录 */ +function ChangeHistoryTab({ profile }: { profile: any }) { + const salaryChanges = profile.salaryChanges || [] + const departmentRecords = profile.departmentRecords || [] + const socialInsRecords = profile.socialInsRecords || [] + const housingFundRecords = profile.housingFundRecords || [] + const terminations = profile.terminations || [] + + const changeTypeMap: Record = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', SALARY_CHANGE: '调薪', TRANSFER: '调部门', CITY_CHANGE: '城市变更' } + const changeTypeColor: Record = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', SALARY_CHANGE: 'bg-indigo-50 text-indigo-600', TRANSFER: 'bg-purple-50 text-purple-600', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' } + + const totalChanges = salaryChanges.length + departmentRecords.length + socialInsRecords.length + housingFundRecords.length + terminations.length + + if (totalChanges === 0) { + return
暂无变更记录
+ } + + return ( +
+ {/* 薪资变更 */} + {salaryChanges.length > 0 && ( + +

+ + 薪资变更历史({salaryChanges.length}条) +

+ + + + + + + + + + + + + + {salaryChanges.map((r: any, idx: number) => ( + + + + + + + + + + ))} + +
生效日期原薪资新薪资变动额类型原因失效年月
{new Date(r.effectiveDate).toLocaleDateString('zh-CN')}¥{fmt(r.oldSalary)}¥{fmt(r.newSalary)}{r.newSalary >= r.oldSalary ? '+' : ''}¥{fmt(r.newSalary - r.oldSalary)}{changeTypeMap[r.changeType] || r.changeType}{r.reason || '-'}{r.endMonth || '至今'}
+
+ )} + + {/* 部门变更 */} + {departmentRecords.length > 0 && ( + +

+ + 部门变更历史({departmentRecords.length}条) +

+ + + + + + + + + + + + + {departmentRecords.map((r: any, idx: number) => ( + + + + + + + + + ))} + +
生效年月原部门新部门类型原因失效年月
{r.effectiveMonth}{r.oldDepartment || '无'}{r.newDepartment}{changeTypeMap[r.changeType] || r.changeType}{r.reason || '-'}{r.endMonth || '至今'}
+
+ )} + + {/* 参保城市变更(社保+公积金合并) */} + {(socialInsRecords.length > 0 || housingFundRecords.length > 0) && ( + + )} + + {/* 离职/解聘记录 */} + {terminations.length > 0 && ( + +

+ + 离职/解聘记录({terminations.length}条) +

+ + + + + + + + + + + + {terminations.map((r: any, idx: number) => ( + + + + + + + + ))} + +
离职日期离职类型原因备注创建时间
{new Date(r.terminationDate).toLocaleDateString('zh-CN')}{terminateReasonMap[r.terminationType] || r.terminationType}{r.reason || '-'}{r.remark || '-'}{new Date(r.createdAt).toLocaleString('zh-CN')}
+
+ )} +
+ ) +} + +/** 考勤/加班/培训合并组件 */ +function AttendanceOvertimeInfo({ employeeId, attendanceRecords, overtimeRecords, trainingRecords }: { employeeId: string; attendanceRecords: any[]; overtimeRecords: any[]; trainingRecords: any[] }) { + const queryClient = useQueryClient() + const [subTab, setSubTab] = useState<'attendance' | 'overtime' | 'training'>('attendance') + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ date: '', checkInTime: '', checkOutTime: '', status: 'NORMAL', lateMinutes: 0, earlyMinutes: 0, workHours: 8, overtimeHours: 0, remark: '' }) + const [trainingForm, setTrainingForm] = useState({ trainingDate: '', topic: '', content: '', trainer: '', duration: 1, ackStatus: 'PENDING', ackDate: '', remark: '' }) + + const createMutation = useMutation({ + mutationFn: (data: any) => api.post(`/roster/${employeeId}/attendance`, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/roster/${employeeId}/attendance/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }), + }) + + const createTrainingMutation = useMutation({ + mutationFn: (data: any) => api.post(`/roster/${employeeId}/training`, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) }, + }) + + const deleteTrainingMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/roster/${employeeId}/training/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }), + }) + + const ackMap: Record = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' } + + const statusMap: Record = { NORMAL: '正常', LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' } + const statusColor: Record = { NORMAL: 'bg-green-50 text-safe', LATE: 'bg-amber-50 text-warning', EARLY_LEAVE: 'bg-amber-50 text-warning', ABSENT: 'bg-red-50 text-danger', LEAVE: 'bg-blue-50 text-blue-600', BUSINESS_TRIP: 'bg-blue-50 text-blue-600' } + + const totalPay = (overtimeRecords || []).reduce((sum, o) => sum + (o.totalPay || 0), 0) + const totalWeekday = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekdayHours || 0), 0) + const totalWeekend = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekendHours || 0), 0) + const totalHoliday = (overtimeRecords || []).reduce((sum, o) => sum + (o.holidayHours || 0), 0) + + return ( +
+
+
+ + + +
+ {subTab === 'attendance' && ( + + )} + {subTab === 'training' && ( + + )} +
+ + {subTab === 'attendance' && ( + <> + {showForm && ( + +
+
setForm({ ...form, date: e.target.value })} />
+
+ +
+
setForm({ ...form, checkInTime: e.target.value })} />
+
setForm({ ...form, checkOutTime: e.target.value })} />
+
setForm({ ...form, lateMinutes: Number(e.target.value) })} />
+
setForm({ ...form, earlyMinutes: Number(e.target.value) })} />
+
setForm({ ...form, workHours: Number(e.target.value) })} />
+
setForm({ ...form, overtimeHours: Number(e.target.value) })} />
+
setForm({ ...form, remark: e.target.value })} />
+
+
+
+ )} + + {attendanceRecords?.length === 0 ? ( +
暂无考勤记录
+ ) : ( + + + + + + + + + + + + + + + {attendanceRecords?.map((a) => ( + + + + + + + + + + ))} + +
日期签到签退状态工时加班
{a.date?.toString().slice(0, 10)}{a.checkInTime || '-'}{a.checkOutTime || '-'}{statusMap[a.status] || a.status}{a.workHours}h{a.overtimeHours > 0 ? `${a.overtimeHours}h` : '-'}
+
+ )} + + )} + + {subTab === 'overtime' && ( + <> + {overtimeRecords?.length === 0 ? ( +
暂无加班记录
+ ) : ( + + + + + + + + + + + + + {overtimeRecords.map((o) => ( + + + + + + + + ))} + + + + + + + + +
月份工作日(h)休息日(h)节假日(h)加班费
{o.month}{o.weekdayHours || '-'}{o.weekendHours || '-'}{o.holidayHours || '-'}¥{fmt(o.totalPay)}
合计{totalWeekday}{totalWeekend}{totalHoliday}¥{fmt(totalPay)}
+
+ )} + + )} + + {subTab === 'training' && ( + <> + {showForm && ( + +
+
setTrainingForm({ ...trainingForm, trainingDate: e.target.value })} />
+
setTrainingForm({ ...trainingForm, topic: e.target.value })} placeholder="如《员工手册》培训" />
+
setTrainingForm({ ...trainingForm, content: e.target.value })} />
+
setTrainingForm({ ...trainingForm, trainer: e.target.value })} />
+
setTrainingForm({ ...trainingForm, duration: Number(e.target.value) })} />
+
+ +
+ {trainingForm.ackStatus === 'SIGNED' &&
setTrainingForm({ ...trainingForm, ackDate: e.target.value })} />
} +
setTrainingForm({ ...trainingForm, remark: e.target.value })} />
+
+
+
+ )} + + {trainingRecords?.length === 0 ? ( +
暂无培训签收记录
+ ) : trainingRecords?.map((r) => ( + +
+
+
+ {r.trainingDate?.toString().slice(0, 10)} + + {r.ackStatus === 'SIGNED' ? '已签收' : r.ackStatus === 'REFUSED' ? '拒绝签收' : '待签收'} + +
+
{r.topic}
+ {r.content &&
{r.content}
} +
+ 时长 {r.duration}h + {r.trainer && 培训人:{r.trainer}} + {r.ackDate && 签收日期:{r.ackDate.toString().slice(0, 10)}} + {r.remark && 备注:{r.remark}} +
+
+ +
+
))} - - 合计 - {totalWeekday} - {totalWeekend} - {totalHoliday} - ¥{fmt(totalPay)} - - - - + + )} +
) } function TerminationInfo({ employeeId, profile, records }: { employeeId: string; profile: any; records: any[] }) { const [printRecord, setPrintRecord] = useState(null) + const [showEvidence, setShowEvidence] = useState(false) const reasonMap: Record = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', @@ -2285,17 +3023,18 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string; EXPIRED: '《劳动合同法》第44条、第46条', ILLEGAL: '《劳动合同法》第87条', } - const { data: evidenceChain } = useQuery({ + const { data: evidenceChain, isLoading: evidenceLoading } = useQuery({ queryKey: ['evidence-chain', employeeId], queryFn: async () => { const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any return res.data }, - enabled: !!printRecord, + enabled: !!printRecord || showEvidence, }) const validRecords = (records || []).filter((t: any) => t.status !== 'CANCELLED') - if (!validRecords.length) return
暂无离职/解聘记录
+ const cancelledRecords = (records || []).filter((t: any) => t.status === 'CANCELLED') + if (!validRecords.length && !cancelledRecords.length) return
暂无离职/解聘记录
if (printRecord) { return ( @@ -2431,23 +3170,23 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string; )}
-
+
{t.type === 'RESIGNATION' ? ( <>
- 离职原因 - {t.resignationReason || '-'} + 离职原因 + {t.resignationReason || '-'}
) : ( <>
- 经济补偿金 - ¥{fmt(t.compensation)} + 经济补偿金 + ¥{fmt(t.compensation)}
- 法律依据 - {legalBasisMap[t.reason] || '-'} + 法律依据 + {legalBasisMap[t.reason] || '-'}
)} @@ -2463,6 +3202,120 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
))} + {cancelledRecords.length > 0 && ( + <> + {validRecords.length > 0 &&
已撤销记录
} + {cancelledRecords.map((t) => ( + +
+
+ {t.terminationDate?.toString().slice(0, 10)} + {t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'} + {reasonMap[t.reason] || t.reason} + 已撤销 +
+
+ {t.type === 'RESIGNATION' ? ( +
+ 离职原因 + {t.resignationReason || '-'} +
+ ) : ( + <> +
+ 经济补偿金 + ¥{fmt(t.compensation)} +
+
+ 法律依据 + {legalBasisMap[t.reason] || '-'} +
+ + )} +
+ {t.remark &&
{t.remark}
} +
+
+ ))} + + )} + + {/* 仲裁证据链 */} +
+ + {showEvidence && ( +
+ {evidenceLoading ? ( +
生成证据链中...
+ ) : evidenceChain ? ( + <> + {evidenceChain.risks && evidenceChain.risks.length > 0 && ( + +
+ 风险提醒({evidenceChain.risks.length}项) +
+
+ {evidenceChain.risks.map((r: any, i: number) => ( +
+ {r.title} + {r.category} +
{r.description}
+
+ ))} +
+
+ )} + {evidenceChain.evidence?.map((e: any, i: number) => ( + +
+ {e.category} +
+
+ {e.title} + {e.date} + {e.acknowledged === true && ✓已签} + {e.acknowledged === false && ⚠未签} + {e.riskLevel === 'HIGH' && ⚠高风险} +
+
{e.description}
+
+
+
+ ))} + + + ) : ( +
无数据
+ )} +
+ )} +
) } diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index 2254e67..e75845f 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' -import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download } from 'lucide-react' +import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock } from 'lucide-react' import api from '../lib/api' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -14,7 +14,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig export default function SocialInsurance() { const queryClient = useQueryClient() const confirm = useConfirm() - const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social') + const [tab, setTab] = useState<'monthly' | 'social' | 'housing'>('monthly') const [city, setCity] = useState('北京') const [base, setBase] = useState(8000) const [showNewVersion, setShowNewVersion] = useState(false) @@ -24,6 +24,8 @@ export default function SocialInsurance() { const [editItems, setEditItems] = useState>({}) const [editingId, setEditingId] = useState(null) const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7)) + const [monthlyProcessed, setMonthlyProcessed] = useState(false) + const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null) const [newVersion, setNewVersion] = useState({ effectiveFrom: new Date().toISOString().slice(0, 7), city: '北京', @@ -83,9 +85,28 @@ export default function SocialInsurance() { enabled: showVersions && tab === 'housing', }) - const { data: monthlyChanges } = useQuery({ - queryKey: ['monthly-changes', monthlyMonth], + // 已办理月份列表(进入月度办理Tab时自动加载) + const { data: processedList, refetch: refetchProcessedList } = useQuery({ + queryKey: ['monthly-process-list'], queryFn: async () => { + const res = await api.get('/social/monthly-process/list') as any + return res.data + }, + enabled: tab === 'monthly', + }) + + // 进入月度办理Tab时自动查询当前月状态 + useEffect(() => { + if (tab === 'monthly') { + api.get('/social/monthly-process/status', { params: { month: monthlyMonth } }).then((res: any) => { + setProcessStatus(res.data) + }).catch(() => {}) + refetchProcessedList() + } + }, [tab]) + + const { mutateAsync: fetchMonthlyChanges, isPending: monthlyLoading, data: monthlyChanges } = useMutation({ + mutationFn: async () => { const [socialRes, housingRes, socialActiveRes, housingActiveRes] = await Promise.all([ api.get('/social/monthly-changes', { params: { month: monthlyMonth } }) as any, api.get('/social/housing/monthly-changes', { params: { month: monthlyMonth } }) as any, @@ -99,7 +120,40 @@ export default function SocialInsurance() { housingActive: housingActiveRes.data, } }, - enabled: tab === 'monthly', + }) + + const handleMonthlyProcess = async () => { + try { + await fetchMonthlyChanges() + setMonthlyProcessed(true) + // 查询该月办理状态 + const statusRes = await api.get('/social/monthly-process/status', { params: { month: monthlyMonth } }) as any + setProcessStatus(statusRes.data) + } catch { + toast.error('获取月度办理数据失败') + } + } + + const completeProcessMutation = useMutation({ + mutationFn: async (type: 'SOCIAL' | 'HOUSING') => { + const snapshot = type === 'SOCIAL' ? monthlyChanges.social : monthlyChanges.housing + const activeSnapshot = type === 'SOCIAL' ? monthlyChanges.socialActive : monthlyChanges.housingActive + const res = await api.post('/social/monthly-process/complete', { + month: monthlyMonth, + type, + snapshot: { changes: snapshot, active: activeSnapshot }, + }) as any + return res.data + }, + onSuccess: (data: any, type: 'SOCIAL' | 'HOUSING') => { + setProcessStatus((prev: any) => ({ ...prev, [type === 'SOCIAL' ? 'social' : 'housing']: data })) + refetchProcessedList() + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + toast.success(`${type === 'SOCIAL' ? '社保' : '公积金'}月度办理已完成并保存`) + }, + onError: () => { + toast.error('保存办理记录失败') + }, }) const { data: result, mutate: calcMutate, isPending } = useMutation({ @@ -164,6 +218,8 @@ export default function SocialInsurance() { onSuccess: (res: any) => { queryClient.invalidateQueries({ queryKey: ['social-config'] }) queryClient.invalidateQueries({ queryKey: ['social-config-versions'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + queryClient.invalidateQueries({ queryKey: ['roster'] }) setShowAdjust(false) setAdjustData(null) setEditItems({}) @@ -178,6 +234,8 @@ export default function SocialInsurance() { onSuccess: (res: any) => { queryClient.invalidateQueries({ queryKey: ['housing-config'] }) queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) + queryClient.invalidateQueries({ queryKey: ['roster'] }) setShowAdjust(false) setAdjustData(null) setEditItems({}) @@ -207,11 +265,15 @@ export default function SocialInsurance() { const handleExportCSV = (type: 'social' | 'housing', data: any) => { if (!data?.items?.length) return const headers = type === 'social' - ? ['姓名', '部门', '社保基数', '开始年月', '截止年月', '变更类型'] - : ['姓名', '部门', '公积金基数', '开始年月', '截止年月', '变更类型'] - const rows = data.items.map((i: any) => [ - i.name, i.department, i.base, i.startMonth, i.endMonth || '', i.changeType - ]) + ? ['姓名', '部门', '社保基数', '企业部分', '个人部分', '合计', '开始年月', '截止年月', '变更类型'] + : ['姓名', '部门', '公积金基数', '企业部分', '个人部分', '合计', '开始年月', '截止年月', '变更类型'] + const rows = data.items.map((i: any) => { + const d = i.detail + if (type === 'social') { + return [i.name, i.department, i.base, d?.totalOrg || '', d?.totalEmp || '', d?.total || '', i.startMonth, i.endMonth || '', i.changeType] + } + return [i.name, i.department, i.base, d?.orgAmount || '', d?.empAmount || '', d?.total || '', i.startMonth, i.endMonth || '', i.changeType] + }) const csv = [headers, ...rows].map(r => r.join(',')).join('\n') const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) @@ -258,31 +320,33 @@ export default function SocialInsurance() { {/* Tab 切换 + 城市选择 */}
- {(['social', 'housing', 'monthly'] as const).map((t) => ( + {(['monthly', 'social', 'housing'] as const).map((t) => ( ))} -
- - -
+ {tab !== 'monthly' && ( +
+ + +
+ )}
{/* ========== 社保 / 公积金 Tab ========== */} @@ -622,131 +686,218 @@ export default function SocialInsurance() {

月度办理

- setMonthlyMonth(e.target.value)} className="!w-32" /> - - + {monthlyProcessed && monthlyChanges && ( + <> + + + + )}
+ {/* 办理状态总览:近12个月时间线 */} + {processedList && (() => { + const now = new Date() + const months: string[] = [] + for (let i = 5; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + } + const socialMonths = new Set(processedList.filter((r: any) => r.type === 'SOCIAL').map((r: any) => r.month)) + const housingMonths = new Set(processedList.filter((r: any) => r.type === 'HOUSING').map((r: any) => r.month)) + const currentMonth = monthlyMonth + return ( +
+
+ + 办理状态总览(近6个月) +
+
+ {months.map((m) => { + const sDone = socialMonths.has(m) + const hDone = housingMonths.has(m) + const isCurrent = m === currentMonth + const allDone = sDone && hDone + const partial = (sDone || hDone) && !allDone + return ( + + ) + })} +
+ {(() => { + const sDone = socialMonths.has(currentMonth) + const hDone = housingMonths.has(currentMonth) + if (sDone && hDone) return
{currentMonth} 社保和公积金均已办理完成
+ if (sDone || hDone) return
{currentMonth} {sDone ? '公积金' : '社保'}尚未办理完成
+ return
{currentMonth} 社保和公积金均未办理
+ })()} +
+ ) + })()} + {/* 办理完成按钮区 */} + {monthlyProcessed && monthlyChanges && ( +
+ + {processStatus?.social && ( + + 社保已办理 {new Date(processStatus.social.processedAt).toLocaleString('zh-CN')} + + )} + + {processStatus?.housing && ( + + 公积金已办理 {new Date(processStatus.housing.processedAt).toLocaleString('zh-CN')} + + )} +
+ )}
展示当月社保/公积金新增(入职/重新入职)、减少(离职/解聘)及正常在保人员列表,用于经办机构申报。
{(() => { - if (!monthlyChanges) return
加载中...
+ if (!monthlyProcessed) { + return
选择月份后点击「获取」按钮,获取当月增减员及在保人员列表
+ } + if (monthlyLoading) return
加载中...
+ if (!monthlyChanges) return
无数据
const sAdd = monthlyChanges.social?.additions || [] - const sSub = monthlyChanges.social?.subtractions || [] + const sSub = monthlyChanges.social?.reductions || [] const sNormal = monthlyChanges.socialActive?.items || [] const hAdd = monthlyChanges.housing?.additions || [] - const hSub = monthlyChanges.housing?.subtractions || [] + const hSub = monthlyChanges.housing?.reductions || [] const hNormal = monthlyChanges.housingActive?.items || [] if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0 && sNormal.length === 0 && hNormal.length === 0) { return
{monthlyMonth} 无办理记录
} + const sConfigs = monthlyChanges.social?.configs || {} + const hConfigs = monthlyChanges.housing?.configs || {} + // 收集所有涉及的城市 + const allCities = [...new Set([ + ...sAdd.map((i: any) => i.city), ...sSub.map((i: any) => i.city), ...sNormal.map((i: any) => i.city), + ...hAdd.map((i: any) => i.city), ...hSub.map((i: any) => i.city), ...hNormal.map((i: any) => i.city), + ])].filter(Boolean).sort() + const renderSocialTable = (city: string) => { + const add = sAdd.filter((i: any) => i.city === city) + const sub = sSub.filter((i: any) => i.city === city) + const normal = sNormal.filter((i: any) => i.city === city) + if (add.length === 0 && sub.length === 0 && normal.length === 0) return null + const cfg = sConfigs[city] + return ( +
+ + + + + + + + + + + + + + + {add.map((i: any) => )} + {sub.map((i: any) => )} + {normal.map((i: any) => )} + + {(add.length > 0 || normal.length > 0) && ( + + + + + + + + + + )} +
姓名部门类型基数企业部分个人部分合计起止年月
社保小计¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))}¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))}¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.total || 0), 0))}
+ {cfg &&
配置版本:{cfg.effectiveFrom} | 基数范围 ¥{fmt(cfg.baseMin)}~¥{fmt(cfg.baseMax)}
} +
+ ) + } + const renderHousingTable = (city: string) => { + const add = hAdd.filter((i: any) => i.city === city) + const sub = hSub.filter((i: any) => i.city === city) + const normal = hNormal.filter((i: any) => i.city === city) + if (add.length === 0 && sub.length === 0 && normal.length === 0) return null + const cfg = hConfigs[city] + return ( +
+ + + + + + + + + + + + + + + {add.map((i: any) => )} + {sub.map((i: any) => )} + {normal.map((i: any) => )} + + {(add.length > 0 || normal.length > 0) && ( + + + + + + + + + + )} +
姓名部门类型基数企业部分个人部分合计起止年月
公积金小计¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))}¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))}¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.total || 0), 0))}
+ {cfg &&
配置版本:{cfg.effectiveFrom} | 企业 {cfg.housingOrg}% / 个人 {cfg.housingEmp}%
} +
+ ) + } return (
- {/* 社保 */} -
-

社保

-
- - - - - - - - - - - - - {sAdd.map((i: any) => ( - - - - - - - - - ))} - {sSub.map((i: any) => ( - - - - - - - - - ))} - {sNormal.map((i: any) => ( - - - - - - - - - ))} - -
姓名部门类型基数开始年月截止年月
{i.name}{i.department}新增¥{fmt(i.base)}{i.startMonth}
{i.name}{i.department}减少¥{fmt(i.base)}{i.endMonth}
{i.name}{i.department}正常¥{fmt(i.base)}{i.startMonth}{i.endMonth || '在保'}
-
-
- {/* 公积金 */} -
-

公积金

-
- - - - - - - - - - - - - {hAdd.map((i: any) => ( - - - - - - - - - ))} - {hSub.map((i: any) => ( - - - - - - - - - ))} - {hNormal.map((i: any) => ( - - - - - - - - - ))} - -
姓名部门类型基数开始年月截止年月
{i.name}{i.department}新增¥{fmt(i.base)}{i.startMonth}
{i.name}{i.department}减少¥{fmt(i.base)}{i.endMonth}
{i.name}{i.department}正常¥{fmt(i.base)}{i.startMonth}{i.endMonth || '在保'}
-
-
+ {allCities.map((city) => { + const sTable = renderSocialTable(city) + const hTable = renderHousingTable(city) + if (!sTable && !hTable) return null + return ( +
+

+ {city} + 向{city}社保/公积金经办机构申报 +

+ {sTable &&

社保

{sTable}
} + {hTable &&

公积金

{hTable}
} +
+ ) + })}
) })()} @@ -760,3 +911,72 @@ export default function SocialInsurance() {
) } + +/** 月度办理社保行组件(可展开查看各险种明细) */ +function MonthlyRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) { + const [expanded, setExpanded] = useState(false) + const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常' + const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500' + const d = i.detail + return ( + <> + setExpanded(!expanded)}> + {i.name} {d && {expanded ? '▾' : '▸'}} + {i.department} + {typeLabel} + ¥{fmt(i.base)} + {d ? `¥${fmt(d.totalOrg)}` : '-'} + {d ? `¥${fmt(d.totalEmp)}` : '-'} + {d ? `¥${fmt(d.total)}` : '-'} + {type === 'add' ? `${i.startMonth} →` : type === 'sub' ? `→ ${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`} + + {expanded && d && ( + + + + + + + + + + + + + + {d.items.map((item: any) => ( + + + + + + + + ))} + +
险种企业比例个人比例企业缴纳个人缴纳
{item.name}{item.orgRate}%{item.empRate > 0 ? `${item.empRate}%` : '-'}¥{fmt(item.orgAmount)}{item.empAmount > 0 ? `¥${fmt(item.empAmount)}` : '-'}
+ + + )} + + ) +} + +/** 月度办理公积金行组件 */ +function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) { + const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常' + const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500' + const d = i.detail + return ( + + {i.name} + {i.department} + {typeLabel} + ¥{fmt(i.base)} + {d ? `¥${fmt(d.orgAmount)}` : '-'} + {d ? `¥${fmt(d.empAmount)}` : '-'} + {d ? `¥${fmt(d.total)}` : '-'} + {type === 'add' ? `${i.startMonth} →` : type === 'sub' ? `→ ${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`} + + ) +} diff --git a/frontend/src/pages/Termination.tsx b/frontend/src/pages/Termination.tsx index f0a049b..381ec94 100644 --- a/frontend/src/pages/Termination.tsx +++ b/frontend/src/pages/Termination.tsx @@ -329,6 +329,7 @@ export default function Termination() { queryClient.invalidateQueries({ queryKey: ['termination-drafts'] }) queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setView('list') }, onError: () => toast.error('撤销失败'),