feat: 社保公积金独立配置+版本化缴费记录+月度增减员+补偿金批次

- Schema: 拆分社保/公积金配置,新增EmployeeSocialInsRecord/EmployeeHousingFundRecord/DepartmentRecord模型,扩展SalaryChangeRecord,增加SEVERANCE批次类型
- 后端: createEmployee/rehireEmployee接收社保公积金字段并创建缴费记录版本;createTermination/createResignation接收截止年月并关闭缴费记录;调薪/调部门API+版本记录;月度增减员API;公积金独立CRUD/计算/调基;SEVERANCE批次calcBatchEntry
- 前端: AddEmployeeModal/RehireModal增加社保公积金输入;ResignModal/Termination增加截止年月+日期不一致提醒;花名册增加调薪/调部门弹窗;SocialInsurance.tsx Tab拆分(社保/公积金/月度增减员)+CSV导出;Money.tsx增加补偿金批次类型
- 修复: seed.ts移除housingOrg/housingEmp;risk.service.ts从HousingFundConfig获取公积金费率
This commit is contained in:
freedakgmail
2026-07-23 20:02:59 +08:00
parent ad6800b63c
commit 4f125d309b
15 changed files with 2227 additions and 335 deletions
+377
View File
@@ -0,0 +1,377 @@
# 社保公积金优化方案
## 核心原则
- 社保和公积金**完全分离**:独立配置、独立调基、独立增减员、独立申报
- 社保公积金开始/截止年月**必填**,增减变以此为准
- 基数缺省等于工资,可修改
- 调薪后社保公积金基数**不自动调整**(社保基数通常每年7月统一调基,调薪仅影响发薪基数)
- 发薪列表和社保/公积金申报列表中,入离职日期与社保公积金年月不一致时**提醒**
- 所有变更(入职/重新入职/调基/调薪/调部门/离职/解聘)都按**版本记录**保存,算薪和月度处理时按月份获取当前有效版本
---
## 一、Schema 改动
### 1.1 拆分配置模型
现有 `SocialInsuranceConfig`(含社保+公积金比例)拆为:
- **`SocialInsuranceConfig`**(保留,移除公积金字段):养老/医疗/失业/工伤/生育比例 + 社保基数上下限 + 生效月份 + 版本管理 + `adjustmentDone` 标记
- **`HousingFundConfig`**(新增):公积金企业/个人比例 + 公积金基数上下限 + 生效月份 + 版本管理 + `adjustmentDone` 标记(字段结构同社保配置)
> Organization 和 Employee 需增加反向关联字段:
> - Organization: `housingFundConfigs HousingFundConfig[]`、`socialInsRecords EmployeeSocialInsRecord[]`、`housingFundRecords EmployeeHousingFundRecord[]`、`departmentRecords EmployeeDepartmentRecord[]``salaryChangeRecords` 已存在)
> - Employee: `socialInsRecords EmployeeSocialInsRecord[]`、`housingFundRecords EmployeeHousingFundRecord[]`、`departmentRecords EmployeeDepartmentRecord[]``salaryChanges` 已存在)
### 1.2 新增模型:社保/公积金缴费记录(按版本保存)
社保和公积金的基数、起止年月不是 Employee 上的简单字段,而是按**版本记录**保存。每次入职/重新入职/调基/离职/解聘都生成新版本,形成完整变更历史。
#### EmployeeSocialInsRecord(社保缴费记录)
```prisma
model EmployeeSocialInsRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
// 变更来源
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth]) // 复合索引:按员工+月份查询有效版本
}
```
#### EmployeeHousingFundRecord(公积金缴费记录)
```prisma
model EmployeeHousingFundRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
// 变更来源
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth]) // 复合索引:按员工+月份查询有效版本
}
```
#### Employee 保留便捷字段(当前生效值,由后端同步维护)
```
socialInsStartMonth String? // 当前社保开始年月(=最新记录的startMonth
socialInsBase Float? // 当前社保基数(=最新记录的base)
socialInsEndMonth String? // 当前社保截止年月(=最新记录的endMonth,null=在保)
housingFundStartMonth String? // 当前公积金开始年月
housingFundBase Float? // 当前公积金基数
housingFundEndMonth String? // 当前公积金截止年月
```
> 这些字段是冗余的便捷查询字段,由后端在创建/更新缴费记录时自动同步。增减员和在职申报查询主要使用 Record 表,发薪计算使用 Employee 便捷字段。
### 1.3 TerminationRecord 增加字段
```
socialInsEndMonth String // 社保截止缴费年月 YYYY-MM(必填)
housingFundEndMonth String // 公积金截止缴费年月 YYYY-MM(必填)
```
> TerminationRecord 保存截止年月的同时,后端自动创建一条 EmployeeSocialInsRecord / EmployeeHousingFundRecord,将上一条有效记录的 endMonth 设为此值,并同步 Employee 便捷字段。
### 1.4 扩展模型:调薪/调部门按版本保存
调薪和调部门也按**版本记录**保存,与社保公积金缴费记录同理。每次变更生成新版本,算薪和社保公积金月度处理时获取当前最新版。
#### 扩展现有 SalaryChangeRecord(增加版本字段)
现有 `SalaryChangeRecord` 已有 `oldSalary`/`newSalary`/`effectiveDate`/`reason`,与其新建模型,直接扩展:
```prisma
// 在现有 SalaryChangeRecord 增加字段:
effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换)
endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置)
changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪
@@index([employeeId, effectiveMonth, endMonth]) // 复合索引
```
> 不新建 `EmployeeSalaryRecord`,直接复用 `SalaryChangeRecord`,避免数据分散。入职时也创建一条(oldSalary=0, newSalary=月薪, changeType=ONBOARDING)。
#### EmployeeDepartmentRecord(部门变更记录,新增模型)
```prisma
model EmployeeDepartmentRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldDepartment String // 调整前部门
newDepartment String // 调整后部门
effectiveMonth String // 生效年月 YYYY-MM
endMonth String? // 失效年月 YYYY-MMnull=至今有效)
reason String? // 调部门原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth]) // 复合索引
}
```
> Employee 上的 `monthlySalary` 和 `department` 作为便捷字段由后端同步维护。
### 1.5 PayrollBatchType 增加枚举
```
SEVERANCE // 补偿金按月发放(无社保,个税按政策处理)
```
### 1.6 数据迁移策略
Schema 改动后,需要为现有员工创建初始 Record:
- **EmployeeSocialInsRecord**:为每个现有员工创建一条,`startMonth` = 入职日期年月,`endMonth` = 已离职员工的离职日期年月(如有),`base` = 现有 `socialInsBase` 或月薪,`changeType` = 'ONBOARDING'
- **EmployeeHousingFundRecord**:同上,`base` = 现有 `housingFundBase` 或月薪
- **SalaryChangeRecord**:为每个现有员工创建一条初始记录,`oldSalary` = 0, `newSalary` = 当前月薪, `effectiveMonth` = 入职日期年月, `endMonth` = null
- **EmployeeDepartmentRecord**:为每个现有员工创建一条,`oldDepartment` = '', `newDepartment` = 当前部门, `effectiveMonth` = 入职日期年月, `endMonth` = null
- **迁移脚本**`npx prisma db push` 后执行一次性迁移脚本 `scripts/migrate-records.ts`
---
## 二、需求1:新增/重新入职填写社保公积金开始年月+基数
### 前端 AddEmployeeModal
- 新增4个必填字段(2列布局):
- 社保开始年月(type=month,缺省=入职日期年月,可修改)
- 社保基数(type=number,缺省=月薪,可修改)
- 公积金开始年月(type=month,缺省=入职日期年月,可修改)
- 公积金基数(type=number,缺省=月薪,可修改)
- 当入职日期变更时(`handleHireDateChange`),自动同步4个缺省值
- `canSubmit` 增加这4个字段的必填校验
### 前端 RehireModal
- 同 AddEmployeeModal,缺省=新入职日期年月
### 后端
- `createEmployeeSchema` 增加 `socialInsStartMonth``socialInsBase``housingFundStartMonth``housingFundBase`(必填)
- `createEmployee` 存储这些字段到 Employee 便捷字段,**同时创建一条 `EmployeeSocialInsRecord`changeType=ONBOARDING)和一条 `EmployeeHousingFundRecord`changeType=ONBOARDING**
- `rehireEmployee` 接收并更新这些字段,**同时创建新版本缴费记录(changeType=REHIRE**,并将之前有效记录的 endMonth 设为重新入职前一个月
---
## 二.5 需求补充:花名册增加调薪/调部门操作
### 前端花名册列表
- 每行操作区增加「调薪」「调部门」按钮(与「离职」并列)
### 前端调薪弹窗(SalaryChangeModal
- 显示:员工姓名、当前月薪、当前部门
- 输入:
- 新月薪(必填,缺省=当前月薪)
- 生效年月(type=month,必填,缺省=当月)
- 调薪原因(选填)
- 提交后:
- 后端创建 `SalaryChangeRecord`oldSalary=当前月薪,newSalary=新月薪,effectiveMonth=生效年月, changeType=SALARY_CHANGE
- 将之前有效记录的 `endMonth` 设为生效月前一个月
- 同步 `Employee.monthlySalary` = 新月薪
### 前端调部门弹窗(DepartmentChangeModal
- 显示:员工姓名、当前部门
- 输入:
- 新部门(必填,缺省=当前部门)
- 生效年月(type=month,必填,缺省=当月)
- 调部门原因(选填)
- 提交后:
- 后端创建 `EmployeeDepartmentRecord`oldDepartment=当前部门,newDepartment=新部门,effectiveMonth=生效年月)
- 将之前有效记录的 `endMonth` 设为生效月前一个月
- 同步 `Employee.department` = 新部门
### 后端
- `POST /roster/:id/salary-change` — 调薪,创建版本记录 + 同步 Employee
- `POST /roster/:id/department-change` — 调部门,创建版本记录 + 同步 Employee
- `GET /roster/:id/salary-records` — 调薪历史
- `GET /roster/:id/department-records` — 调部门历史
### 算薪和社保公积金月度处理
- 算薪时:根据发薪月份获取该月有效的 `SalaryChangeRecord``effectiveMonth <= month``endMonth == null 或 >= month`),使用该记录的 `newSalary` 作为发薪基数
- 社保公积金月度处理时:根据月份获取该月有效的 `EmployeeSocialInsRecord` / `EmployeeHousingFundRecord`,使用该记录的 `base` 作为缴费基数
- 部门信息:根据月份获取该月有效的 `EmployeeDepartmentRecord`,用于月度报表中的部门归属
- **调薪与社保基数关系**:调薪仅影响发薪基数,**不自动调整**社保公积金基数。社保公积金基数仅在每年7月统一调基时调整
---
## 三、需求2:离职/解聘填写社保公积金截止年月
### 前端 ResignModalRoster.tsx
- 新增2个必填字段:
- 社保截止年月(type=month,缺省=离职日期年月,可修改)
- 公积金截止年月(type=month,缺省=离职日期年月,可修改)
- 当离职日期变更时,自动同步缺省值
- `canSubmit` 增加必填校验
### 前端 Termination.tsx(解聘向导 Step 1
- 在解聘日期下方增加社保截止年月、公积金截止年月输入
- 缺省=解聘日期年月,可修改
### 后端
- `terminationChecklistSchema` 增加 `socialInsEndMonth``housingFundEndMonth`(必填)
- `createTermination``createResignation` 存储这些字段到 TerminationRecord
- **同时更新 Employee 便捷字段**`socialInsEndMonth``housingFundEndMonth`
- **同时创建/更新缴费记录**:将当前有效记录的 `endMonth` 设为截止年月,同步 Employee 便捷字段
---
## 四、需求3:社保公积金Tab增加月度增减员+在职申报+导出
### 4.1 前端 SocialInsurance.tsx 改造
增加顶层 Tab 切换:
- **「社保」Tab**:社保配置管理 + 社保调基 + 社保月度增减员 + 社保在职申报
- **「公积金」Tab**:公积金配置管理 + 公积金调基 + 公积金月度增减员 + 公积金在职申报
每个 Tab 内再分子 Tab
- 配置管理(现有功能,社保/公积金各自独立)
- 月度增减员
- 在职申报
### 4.2 月度增减员
**后端 API**
- `GET /social/monthly-changes?month=YYYY-MM` — 社保增减员
- `GET /housing/monthly-changes?month=YYYY-MM` — 公积金增减员
**逻辑**(统一使用 Record 表查询,确保历史月份也能查到已离职员工):
- **增员**:查 `EmployeeSocialInsRecord.startMonth == month`(姓名、部门、基数、开始年月、changeType)
- **减员**:查 `EmployeeSocialInsRecord.endMonth == month``changeType == 'TERMINATION'`(姓名、部门、基数、截止年月、离职类型)
- 支持导出 CSV(前端生成,无需后端依赖)
**前端**:选择月份 → 显示增员表和减员表(两个表格或折叠分区)→ 导出按钮
### 4.3 在职申报
**后端 API**
- `GET /social/active-declaration?month=YYYY-MM` — 社保在保人员
- `GET /housing/active-declaration?month=YYYY-MM` — 公积金在保人员
**逻辑**(使用 Record 表查询):
- 筛选条件:`EmployeeSocialInsRecord.startMonth <= month``endMonth == null 或 >= month`
- 返回:姓名、身份证号、部门、社保基数、开始年月、截止年月
- 支持导出 CSV(前端生成,无需后端依赖)
**前端**:选择月份 → 显示在保人员表格 → 导出按钮
### 4.4 调基拆分
现有调基操作同时调整社保和公积金基数。改为:
- 社保调基:只调整社保基数,使用 `SocialInsuranceConfig` 的上下限
- 将当前有效记录的 `endMonth` 设为调基月前一个月
- 创建新 `EmployeeSocialInsRecord`changeType=ADJUST),startMonth=调基月,base=新基数
- 同步 Employee.socialInsBase / socialInsStartMonth
- 公积金调基:只调整公积金基数,使用 `HousingFundConfig` 的上下限
- 将当前有效记录的 `endMonth` 设为调基月前一个月
- 创建新 `EmployeeHousingFundRecord`changeType=ADJUST),startMonth=调基月,base=新基数
- 同步 Employee.housingFundBase / housingFundStartMonth
- 两个调基操作独立执行,各自有 `adjustmentDone` 标记
---
## 五、需求4:已离职员工按月发放补偿金
### 后端
- `PayrollBatchType` 增加 `SEVERANCE`
- `calcBatchEntry`:当 `batchType === 'SEVERANCE'` 时:
- `socialEmp=0``housingEmp=0``socialOrg=0``housingOrg=0`(无社保公积金)
- `tax`:经济补偿金在当地社平工资3倍以内免征个税,超过部分按单独税率计税。简化处理:`tax=0`,备注注明「补偿金免征个税(社平3倍以内)」,如超过3倍需手动计算
- 允许 `status === 'RESIGNED'` 的员工加入 `SEVERANCE` 批次
- 补偿金发放可设置**发放月数**(如约定发放6个月),到期后自动标记为已完成
- 也可手动停止发放
### 前端 Money.tsx
- 批次类型下拉增加「补偿金发放」选项
- `SEVERANCE` 批次:员工选择列表包含已离职员工
- 输入项简化:只有补偿金金额(baseSalary),无加班/津贴/扣款
- 可设置发放月数
- 工资条显示:社保=0、公积金=0、个税=0(备注:补偿金免征)
---
## 六、需求5:日期不一致提醒
### 发薪列表提醒
在发薪批次详情中,对每个员工检查发薪月份与入离职日期的一致性:
- 发薪月份 < 入职日期年月 → ⚠️ "该员工2025-07入职,当前发薪月份2025-06尚未入职"
- 发薪月份 > 离职日期年月 → ⚠️ "该员工已于2025-06离职,当前发薪月份2025-07已离职"
- 同时也检查社保公积金年月范围,如有不一致也提醒
### 社保/公积金申报列表提醒
- **增员**`socialInsStartMonth``hireDate` 年月不一致 → ⚠️ "社保开始年月与入职日期不一致"
- **减员**`socialInsEndMonth``terminationDate` 年月不一致 → ⚠️ "社保截止年月与离职日期不一致"
- **在职申报**`hireDate` 年月与 `socialInsStartMonth` 不一致、`terminationDate` 年月与 `socialInsEndMonth` 不一致 → ⚠️ 提醒
---
## 七、实施顺序
| 步骤 | 内容 | 涉及 |
|------|------|------|
| 1 | Schema 改动(拆分配置、新增缴费/部门记录模型、扩展SalaryChangeRecord、增加字段、增加枚举)+ `prisma db push` | 后端 |
| 1.5 | 数据迁移脚本:为现有员工创建初始 Record | 后端 |
| 2 | 后端:`createEmployee`/`rehireEmployee` 接收社保公积金字段 + 创建缴费记录版本 | 后端 |
| 3 | 后端:`createTermination`/`createResignation` 接收截止年月 + 更新缴费记录版本 | 后端 |
| 3.5 | 后端:调薪/调部门 API + 创建/扩展版本记录 + 同步 Employee | 后端 |
| 4 | 前端:AddEmployeeModal 增加社保公积金输入 | 前端 |
| 5 | 前端:RehireModal 同步 | 前端 |
| 6 | 前端:ResignModal 增加截止年月 | 前端 |
| 6.5 | 前端:花名册增加调薪/调部门弹窗 | 前端 |
| 7 | 前端:Termination.tsx 解聘向导增加截止年月 | 前端 |
| 8 | 后端:月度增减员 + 在职申报 API | 后端 |
| 9 | 后端:`SEVERANCE` 批次类型 + `calcBatchEntry` 修改 | 后端 |
| 10 | 前端:SocialInsurance.tsx 改造(Tab拆分+增减员+申报+导出) | 前端 |
| 11 | 前端:Money.tsx 增加补偿金批次 | 前端 |
| 12 | 前端:日期不一致提醒 | 前端 |
| 13 | 编译验证 + git 推送 | 全部 |
+101 -7
View File
@@ -55,6 +55,7 @@ enum PayrollBatchType {
REGULAR // 常规发薪
TERMINATION // 离职结算
BONUS // 年终奖/奖金
SEVERANCE // 补偿金按月发放(无社保,个税按政策处理)
}
enum PayrollBatchStatus {
@@ -131,6 +132,10 @@ model Organization {
onboardingLinks OnboardingLink[]
confirmLinks ContractConfirmLink[]
socialInsuranceConfig SocialInsuranceConfig[]
housingFundConfigs HousingFundConfig[]
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
notificationSetting NotificationSetting?
overtimeConfig OvertimeConfig?
notificationLogs NotificationLog[]
@@ -178,8 +183,12 @@ model Employee {
isInMedicalPeriod Boolean @default(false)
isWorkInjured Boolean @default(false)
// 薪税扩展
socialInsBase Float? // 社保缴费基数(按人核定
housingFundBase Float? // 公积金缴费基数(按人核定
socialInsBase Float? // 社保缴费基数(便捷字段,由Record同步
housingFundBase Float? // 公积金缴费基数(便捷字段,由Record同步
socialInsStartMonth String? // 当前社保开始年月(便捷字段)
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
createdBy String
createdAt DateTime @default(now())
@@ -197,6 +206,9 @@ model Employee {
attendanceRecords AttendanceRecord[]
trainingRecords TrainingRecord[]
performanceRecords PerformanceRecord[]
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
}
model LaborContract {
@@ -256,6 +268,8 @@ model TerminationRecord {
terminationDate DateTime
resignationReason String? // 主动离职原因(type=RESIGNATION时使用)
compensation Float @default(0)
socialInsEndMonth String? // 社保截止缴费年月 YYYY-MM
housingFundEndMonth String? // 公积金截止缴费年月 YYYY-MM
riskLevel RiskAssessment @default(SAFE)
checklist Json
remark String?
@@ -315,14 +329,33 @@ model SocialInsuranceConfig {
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
maternityOrg Float @default(0.8) // 生育保险 企业比例 %
housingOrg Float @default(12) // 公积金 企业比例 %
housingEmp Float @default(12) // 公积金 个人比例 %
baseMin Float @default(6326) // 缴费基数下限
baseMax Float @default(33891) // 缴费基数上限
baseMin Float @default(6326) // 社保缴费基数下限
baseMax Float @default(33891) // 社保缴费基数上限
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true) // 是否当前生效版本
adjustmentDone Boolean @default(false) // 是否已执行过员工基数调整
adjustmentDone Boolean @default(false) // 是否已执行过社保基数调整
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, effectiveFrom])
@@index([orgId, isCurrent])
}
model HousingFundConfig {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
city String @default("北京")
housingOrg Float @default(12) // 公积金 企业比例 %
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
@@ -613,11 +646,72 @@ model SalaryChangeRecord {
oldSalary Float
newSalary Float
effectiveDate DateTime // 生效日期
effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换)
endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置)
changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪
reason String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth])
}
model EmployeeSocialInsRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth])
}
model EmployeeHousingFundRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth])
}
model EmployeeDepartmentRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldDepartment String // 调整前部门
newDepartment String // 调整后部门
effectiveMonth String // 生效年月 YYYY-MM
endMonth String? // 失效年月 YYYY-MMnull=至今有效)
reason String? // 调部门原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth])
}
model OnboardingLink {
-2
View File
@@ -109,8 +109,6 @@ async function main() {
unemploymentEmp: 0.5,
injuryOrg: 0.2,
maternityOrg: 0.8,
housingOrg: 7,
housingEmp: 7,
baseMin: 7384,
baseMax: 36921,
effectiveFrom: '2025-07',
+150
View File
@@ -0,0 +1,150 @@
/**
* 一次性迁移脚本:为现有员工创建初始版本记录
* 运行方式:npx tsx scripts/migrate-records.ts
*/
import prisma from '../src/lib/prisma.js'
import { decrypt } from '../src/lib/crypto.js'
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
async function main() {
const employees = await prisma.employee.findMany({
include: {
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
salaryChanges: { orderBy: { createdAt: 'desc' }, take: 1 },
socialInsRecords: { take: 1 },
housingFundRecords: { take: 1 },
departmentRecords: { take: 1 },
},
})
console.log(`Found ${employees.length} employees to migrate`)
for (const emp of employees) {
const hireMonth = dateToMonth(emp.hireDate)
const termination = emp.terminations[0]
const endMonth = termination ? dateToMonth(termination.terminationDate) : null
// 解密月薪获取数值
let salaryNum = 0
try {
salaryNum = parseFloat(decrypt(emp.monthlySalary)) || 0
} catch {
salaryNum = parseFloat(emp.monthlySalary) || 0
}
const socialInsBase = emp.socialInsBase ?? salaryNum
const housingFundBase = emp.housingFundBase ?? salaryNum
// 1. 社保缴费记录(仅当尚无记录时创建)
if (emp.socialInsRecords.length === 0) {
await prisma.employeeSocialInsRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
startMonth: emp.socialInsStartMonth || hireMonth,
endMonth: endMonth || emp.socialInsEndMonth || null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 2. 公积金缴费记录
if (emp.housingFundRecords.length === 0) {
await prisma.employeeHousingFundRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
startMonth: emp.housingFundStartMonth || hireMonth,
endMonth: endMonth || emp.housingFundEndMonth || null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 3. 薪资变更记录(仅当尚无记录时创建)
if (emp.salaryChanges.length === 0) {
await prisma.salaryChangeRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: emp.hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
} else {
// 已有记录但缺少 effectiveMonth/endMonth/changeType,补充
const latest = emp.salaryChanges[0]
if (!latest.effectiveMonth || !latest.changeType) {
await prisma.salaryChangeRecord.update({
where: { id: latest.id },
data: {
effectiveMonth: dateToMonth(latest.effectiveDate),
changeType: latest.changeType || 'SALARY_CHANGE',
},
})
}
}
// 4. 部门变更记录
if (emp.departmentRecords.length === 0) {
await prisma.employeeDepartmentRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
oldDepartment: '',
newDepartment: emp.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 5. 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsStartMonth: emp.socialInsStartMonth || hireMonth,
socialInsEndMonth: endMonth || emp.socialInsEndMonth || null,
socialInsBase,
housingFundStartMonth: emp.housingFundStartMonth || hireMonth,
housingFundEndMonth: endMonth || emp.housingFundEndMonth || null,
housingFundBase,
},
})
console.log(`${emp.name} (${emp.department}) — records created/synced`)
}
console.log('\nMigration complete!')
}
main()
.catch((e) => {
console.error('Migration failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+4 -4
View File
@@ -182,7 +182,7 @@ 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'),
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
sourceBatchId: z.string().optional(),
name: z.string().optional(),
@@ -211,7 +211,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1)
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`
const batchName = name || `${month}${batchNo}${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : '发薪'}`
const batchName = name || `${month}${batchNo}${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : type === 'SEVERANCE' ? '补偿金' : '发薪'}`
// 根据模式确定员工列表和数据来源
let employees: any[] = []
@@ -236,7 +236,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
})
} else {
// copy_last 或 blank_employees:拉入员工
if (type === 'TERMINATION') {
if (type === 'TERMINATION' || type === 'SEVERANCE') {
const terminations = await prisma.terminationRecord.findMany({
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
@@ -317,7 +317,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
// 计算社保、个税等
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
const skipSocial = type !== 'BONUS' && hasArchivedRegularBatch > 0
const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
// 风险提示
+132 -1
View File
@@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
const router = Router()
@@ -570,4 +570,135 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req:
} catch (err) { next(err) }
})
// ========== 调薪/调部门 API ==========
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
// 调薪
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newSalary, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const oldSalary = safeDecrypt(employee.monthlySalary)
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth },
})
// 创建新薪资记录
const record = await prisma.salaryChangeRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.id,
oldSalary,
newSalary: Number(newSalary),
effectiveDate: new Date(`${effMonth}-01`),
effectiveMonth: effMonth,
endMonth: null,
changeType: 'SALARY_CHANGE',
reason: reason || null,
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: req.params.id },
data: { monthlySalary: encrypt(String(newSalary)) },
})
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 调薪历史
router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.salaryChangeRecord.findMany({
where: { employeeId: req.params.id, orgId: req.user!.orgId },
orderBy: { effectiveDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 调部门
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newDepartment, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const oldDepartment = employee.department
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 关闭之前有效记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth },
})
// 创建新部门记录
const record = await prisma.employeeDepartmentRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.id,
oldDepartment,
newDepartment,
effectiveMonth: effMonth,
endMonth: null,
changeType: 'TRANSFER',
reason: reason || null,
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: req.params.id },
data: { department: newDepartment },
})
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 调部门历史
router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.employeeDepartmentRecord.findMany({
where: { employeeId: req.params.id, orgId: req.user!.orgId },
orderBy: { effectiveMonth: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
export default router
+465 -21
View File
@@ -7,7 +7,7 @@ import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
const configFields = {
const socialConfigFields = {
city: z.string().optional(),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
@@ -17,6 +17,12 @@ const configFields = {
unemploymentEmp: z.number().optional(),
injuryOrg: z.number().optional(),
maternityOrg: z.number().optional(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
}
const housingConfigFields = {
city: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
baseMin: z.number().optional(),
@@ -85,7 +91,7 @@ router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, ne
// 新建版本(年度调基/比例变更)
const createVersionSchema = z.object({
...configFields,
...socialConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
@@ -147,7 +153,7 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true, name: true, department: true, socialInsBase: true, housingFundBase: true, monthlySalary: true },
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
@@ -180,21 +186,16 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
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,
}
})
@@ -209,7 +210,6 @@ const adjustApplySchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newSocialBase: z.number(),
newHousingBase: z.number(),
})),
})
@@ -225,19 +225,40 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const { items } = adjustApplySchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
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.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新社保记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: item.employeeId,
startMonth: adjustMonth,
endMonth: null,
base: socialBase,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: {
socialInsBase: socialBase,
housingFundBase: housingBase,
},
data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth },
})
adjusted++
}
@@ -296,11 +317,8 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
const injuryOrg = actualBase * config.injuryOrg / 100
const maternityOrg = actualBase * config.maternityOrg / 100
const housingOrg = actualBase * config.housingOrg / 100
const housingEmp = actualBase * config.housingEmp / 100
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg + housingOrg
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp + housingEmp
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
const total = totalOrg + totalEmp
res.json({
@@ -317,7 +335,6 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
{ name: '住房公积金', orgRate: config.housingOrg, empRate: config.housingEmp, orgAmount: housingOrg, empAmount: housingEmp },
],
totalOrg,
totalEmp,
@@ -329,4 +346,431 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
}
})
// ========== 公积金配置 ==========
// 获取当前公积金配置
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let config = await prisma.housingFundConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
config = await prisma.housingFundConfig.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('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const versions = await prisma.housingFundConfig.findMany({
where: { orgId: req.user!.orgId },
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 新建公积金配置版本
const createHousingVersionSchema = z.object({
...housingConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createHousingVersionSchema.parse(req.body)
const orgId = req.user!.orgId
const existing = await prisma.housingFundConfig.findUnique({
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
}
const prevCurrent = await prisma.housingFundConfig.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.housingFundConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
const version = await prisma.housingFundConfig.create({
data: {
orgId,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 公积金计算
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
if (month) {
config = await prisma.housingFundConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.housingFundConfig.findFirst({
where: { orgId, isCurrent: true },
})
}
if (!config) {
config = await prisma.housingFundConfig.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)
const housingOrg = actualBase * config.housingOrg / 100
const housingEmp = actualBase * config.housingEmp / 100
res.json({
success: true,
data: {
actualBase,
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
housingOrg,
housingEmp,
total: housingOrg + housingEmp,
},
})
} catch (err) {
next(err)
}
})
// 公积金调基预览
router.get('/housing-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.housingFundConfig.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, housingFundBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
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 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)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldBase = emp.housingFundBase ?? monthlyWage
const payslips = empPayslipMap.get(emp.id)
const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage
const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldBase,
avgSalary,
monthlyWage,
suggestedBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行公积金调基
const adjustHousingSchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newBase: z.number(),
})),
})
router.post('/housing-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.housingFundConfig.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 } = adjustHousingSchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
let adjusted = 0
for (const item of items) {
const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: item.employeeId,
startMonth: adjustMonth,
endMonth: null,
base,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: { housingFundBase: base, housingFundStartMonth: adjustMonth },
})
adjusted++
}
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// ========== 月度增减员 ==========
// 社保月度增减员
router.get('/monthly-changes', 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
// 增员:startMonth == month
const additions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
// 减员:endMonth == month 且 changeType == TERMINATION
const reductions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
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,
})),
},
})
} catch (err) {
next(err)
}
})
// 公积金月度增减员
router.get('/housing/monthly-changes', 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 additions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
const reductions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
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,
})),
},
})
} catch (err) {
next(err)
}
})
// ========== 在职申报 ==========
// 社保在保人员
router.get('/active-declaration', 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.employeeSocialInsRecord.findMany({
where: {
orgId,
startMonth: { lte: month },
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
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,
})),
},
})
} catch (err) {
next(err)
}
})
// 公积金在保人员
router.get('/housing/active-declaration', 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.employeeHousingFundRecord.findMany({
where: {
orgId,
startMonth: { lte: month },
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
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,
})),
},
})
} catch (err) {
next(err)
}
})
export default router
+184 -2
View File
@@ -6,6 +6,18 @@ function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
}
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
export function getContractStatus(contract: {
signDate: Date | null
startDate: Date
@@ -155,12 +167,20 @@ export async function getEmployeeDetail(orgId: string, id: string) {
}
export async function createEmployee(orgId: string, userId: string, data: any) {
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
const employee = await prisma.employee.create({
data: {
orgId,
name: data.name,
department: data.department,
hireDate: new Date(data.hireDate),
hireDate,
monthlySalary: encrypt(data.monthlySalary),
gender: data.gender,
phone: data.phone,
@@ -168,6 +188,65 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
housingFundStartMonth,
createdBy: userId,
},
})
// 创建社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
// 创建公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
// 创建初始薪资变更记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: employee.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
// 创建初始部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: employee.id,
oldDepartment: '',
newDepartment: data.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
@@ -227,6 +306,38 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' }
}
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
const prevHireMonth = prevMonth(newHireMonth)
// 关闭旧社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧薪资记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧部门记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
await prisma.employee.update({
where: { id },
data: {
@@ -236,6 +347,67 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
isPregnant: false,
isInMedicalPeriod: false,
isWorkInjured: false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
socialInsEndMonth: null,
housingFundStartMonth,
housingFundEndMonth: null,
},
})
// 创建新社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'REHIRE',
createdBy: userId,
},
})
// 创建新公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'REHIRE',
createdBy: userId,
},
})
// 创建新薪资记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary: salaryNum,
newSalary: salaryNum,
effectiveDate: newHireDate,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
createdBy: userId,
},
})
// 创建新部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: id,
oldDepartment: employee.department,
newDepartment: data.department || employee.department,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
createdBy: userId,
},
})
@@ -287,13 +459,23 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
updateData.monthlySalary = encrypt(data.monthlySalary)
// 记录薪资变更
if (oldSalary !== newSalary) {
const now = new Date()
const nowMonth = dateToMonth(now)
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevMonth(nowMonth) },
})
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary,
newSalary,
effectiveDate: new Date(),
effectiveDate: now,
effectiveMonth: nowMonth,
endMonth: null,
changeType: 'SALARY_CHANGE',
reason: data.salaryChangeReason || '手动调整',
createdBy: '',
},
+15 -5
View File
@@ -117,7 +117,7 @@ export async function calcBatchEntry(
batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
) {
const [employee, socialConfig] = await Promise.all([
const [employee, socialConfig, housingConfig] = await Promise.all([
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
prisma.socialInsuranceConfig.findFirst({
where: {
@@ -127,6 +127,14 @@ export async function calcBatchEntry(
},
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
])
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
@@ -136,13 +144,15 @@ export async function calcBatchEntry(
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
// 年终奖/奖金批次:不扣社保公积金
if (batchType !== 'BONUS' && !options?.skipSocial) {
// 年终奖/奖金批次、补偿金批次:不扣社保公积金
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) {
if (socialConfig) {
const social = calcSocialInsurance(socialBase, socialConfig)
const housing = calcHousingFund(housingBase, socialConfig)
socialEmp = social.socialEmp
socialOrg = social.socialOrg
}
if (housingConfig) {
const housing = calcHousingFund(housingBase, housingConfig)
housingEmp = housing.housingEmp
housingOrg = housing.housingOrg
}
@@ -164,7 +174,7 @@ export async function calcBatchEntry(
// 年终奖单独计税
tax = calcBonusTax(inputs.bonus)
} else {
// 累计预扣法
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
const year = month.slice(0, 4)
const prevPayslips = await prisma.payslip.findMany({
where: {
+4 -3
View File
@@ -282,7 +282,7 @@ export async function getDashboardData(orgId: string) {
const [
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
overtimeRecords, payslips, batchEntries, socialConfig,
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
monthSeverancePay,
] = await Promise.all([
@@ -315,6 +315,7 @@ export async function getDashboardData(orgId: string) {
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.findFirst({ where: { orgId, isCurrent: true } }),
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.laborContract.count({
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
}),
@@ -408,8 +409,8 @@ export async function getDashboardData(orgId: string) {
const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin
socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount
socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount
housingOrgTotal = avgBase * socialConfig.housingOrg / 100 * employeeCount
housingEmpTotal = avgBase * socialConfig.housingEmp / 100 * employeeCount
housingOrgTotal = avgBase * (housingConfig?.housingOrg ?? 0) / 100 * employeeCount
housingEmpTotal = avgBase * (housingConfig?.housingEmp ?? 0) / 100 * employeeCount
}
// 个税:优先用归档批次的实际计算值,否则估算
+56 -6
View File
@@ -6,6 +6,12 @@ function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
}
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
export interface ChecklistItem {
key: string
label: string
@@ -152,14 +158,21 @@ export async function createTermination(orgId: string, userId: string, data: any
const { level } = assessRisk(employee, data.reason)
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'TERMINATION',
reason: data.reason,
terminationDate: new Date(data.terminationDate),
terminationDate: termDate,
compensation: data.compensation || 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: level,
checklist: data.checklist || {},
remark: data.remark,
@@ -167,15 +180,30 @@ export async function createTermination(orgId: string, userId: string, data: any
},
})
// 关闭社保缴费记录(设置 endMonth)
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据解聘日期判断在职/离职状态
const termDate = new Date(data.terminationDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
@@ -202,15 +230,22 @@ export async function createResignation(orgId: string, userId: string, data: any
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' }
}
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'RESIGNATION',
reason: 'RESIGNATION',
terminationDate: new Date(data.terminationDate),
terminationDate: termDate,
resignationReason: data.resignationReason || null,
compensation: 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: 'SAFE',
checklist: {},
remark: data.remark || null,
@@ -218,15 +253,30 @@ export async function createResignation(orgId: string, userId: string, data: any
},
})
// 关闭社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据离职日期判断在职/离职状态
const termDate = new Date(data.terminationDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
+3 -1
View File
@@ -56,7 +56,7 @@ function BatchManager() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS'>('REGULAR')
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch'>('copy_last')
const [sourceBatchId, setSourceBatchId] = useState<string>('')
const [page, setPage] = useState(1)
@@ -143,6 +143,7 @@ function BatchManager() {
<option value="REGULAR"></option>
<option value="TERMINATION"></option>
<option value="BONUS">/</option>
<option value="SEVERANCE"></option>
</Select>
</div>
<div>
@@ -206,6 +207,7 @@ function BatchManager() {
{batch.employeeCount} · ¥{fmt(batch.totalPay)} · ¥{fmt(batch.totalNetPay)}
{batch.type === 'BONUS' && ' · 单独计税'}
{batch.type === 'TERMINATION' && ' · 离职结算'}
{batch.type === 'SEVERANCE' && ' · 补偿金'}
</div>
</div>
</div>
+292 -10
View File
@@ -23,6 +23,10 @@ export default function Roster() {
const [resignEmployee, setResignEmployee] = useState<any>(null)
const [showRehireModal, setShowRehireModal] = useState(false)
const [rehireEmployee, setRehireEmployee] = useState<any>(null)
const [showSalaryModal, setShowSalaryModal] = useState(false)
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
@@ -71,6 +75,26 @@ export default function Roster() {
},
})
const salaryChangeMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${salaryEmployee?.id}/salary-change`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowSalaryModal(false)
setSalaryEmployee(null)
},
})
const deptChangeMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${deptEmployee?.id}/department-change`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowDeptModal(false)
setDeptEmployee(null)
},
})
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
) || []
@@ -179,16 +203,38 @@ export default function Roster() {
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
<td className="py-2 px-3 text-center">
{e.status === 'ACTIVE' && !e.hasTermination && (
<button
className="text-xs text-gray-500 hover:text-danger flex items-center gap-0.5"
onClick={(ev) => {
ev.stopPropagation()
setResignEmployee(e)
setShowResignModal(true)
}}
>
<UserX className="w-3.5 h-3.5" />
</button>
<div className="flex items-center justify-center gap-2">
<button
className="text-xs text-gray-500 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
setSalaryEmployee(e)
setShowSalaryModal(true)
}}
>
</button>
<button
className="text-xs text-gray-500 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
setDeptEmployee(e)
setShowDeptModal(true)
}}
>
</button>
<button
className="text-xs text-gray-500 hover:text-danger flex items-center gap-0.5"
onClick={(ev) => {
ev.stopPropagation()
setResignEmployee(e)
setShowResignModal(true)
}}
>
<UserX className="w-3.5 h-3.5" />
</button>
</div>
)}
{e.hasTermination && e.status === 'ACTIVE' && (
<div className="flex items-center justify-center gap-2">
@@ -257,6 +303,26 @@ export default function Roster() {
error={rehireMutation.error as any}
/>
)}
{showSalaryModal && salaryEmployee && (
<SalaryChangeModal
employee={salaryEmployee}
onClose={() => { setShowSalaryModal(false); setSalaryEmployee(null) }}
onSubmit={(data) => salaryChangeMutation.mutate(data)}
loading={salaryChangeMutation.isPending}
error={salaryChangeMutation.error as any}
/>
)}
{showDeptModal && deptEmployee && (
<DeptChangeModal
employee={deptEmployee}
onClose={() => { setShowDeptModal(false); setDeptEmployee(null) }}
onSubmit={(data) => deptChangeMutation.mutate(data)}
loading={deptChangeMutation.isPending}
error={deptChangeMutation.error as any}
/>
)}
</div>
)
}
@@ -729,6 +795,136 @@ function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string;
)
}
function SalaryChangeModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({
newSalary: '',
effectiveDate: todayStr,
reason: '',
})
const handleSubmit = () => {
onSubmit({
newSalary: parseFloat(form.newSalary),
effectiveDate: new Date(form.effectiveDate).toISOString(),
reason: form.reason || undefined,
})
}
const canSubmit = form.newSalary && parseFloat(form.newSalary) > 0 && form.effectiveDate
return (
<Modal open onClose={onClose} title={`调薪 - ${employee.name}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">¥{fmt(employee.monthlySalary)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input type="number" value={form.newSalary} onChange={(e) => setForm({ ...form, newSalary: e.target.value })} placeholder="元" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调薪'}</Button>
</div>
</div>
</Modal>
)
}
function DeptChangeModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({
newDepartment: employee.department || '',
effectiveDate: todayStr,
reason: '',
})
const handleSubmit = () => {
onSubmit({
newDepartment: form.newDepartment,
effectiveDate: new Date(form.effectiveDate).toISOString(),
reason: form.reason || undefined,
})
}
const canSubmit = form.newDepartment && form.effectiveDate && form.newDepartment !== employee.department
return (
<Modal open onClose={onClose} title={`调部门 - ${employee.name}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.department}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input value={form.newDepartment} onChange={(e) => setForm({ ...form, newDepartment: e.target.value })} placeholder="如:市场部" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调部门'}</Button>
</div>
</div>
</Modal>
)
}
function ResignModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
@@ -740,8 +936,12 @@ function ResignModal({ employee, onClose, onSubmit, loading, error }: {
terminationDate: new Date().toISOString().slice(0, 10),
resignationReason: '个人原因',
remark: '',
socialInsEndMonth: '',
housingFundEndMonth: '',
})
const terminationMonth = form.terminationDate ? form.terminationDate.slice(0, 7) : ''
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
const handleSubmit = () => {
@@ -750,6 +950,8 @@ function ResignModal({ employee, onClose, onSubmit, loading, error }: {
terminationDate: new Date(form.terminationDate).toISOString(),
resignationReason: form.resignationReason,
remark: form.remark || undefined,
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
})
}
@@ -777,6 +979,26 @@ function ResignModal({ employee, onClose, onSubmit, loading, error }: {
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
</Select>
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="month" value={form.socialInsEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, socialInsEndMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, housingFundEndMonth: e.target.value })} />
</div>
</div>
{((form.socialInsEndMonth && form.socialInsEndMonth !== terminationMonth) || (form.housingFundEndMonth && form.housingFundEndMonth !== terminationMonth)) && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
/
</div>
)}
</div>
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
@@ -821,7 +1043,10 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: {
contractYears: 3,
probationMonths: 0,
probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
})
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 计算合同月数
const contractMonths = (() => {
@@ -914,6 +1139,10 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: {
const data: any = {
hireDate: new Date(form.hireDate).toISOString(),
department: form.department,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
housingFundStartMonth: form.housingFundStartMonth || undefined,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
@@ -953,6 +1182,28 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: {
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
</div>
</div>
</div>
<div className="border-t pt-3">
<Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any, endDate: e.target.value === 'UNFIXED' ? '' : form.endDate })}>
@@ -1039,8 +1290,13 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
})
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
const salaryNum = parseFloat(form.monthlySalary) || 0
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
const handleHireDateChange = (hireDate: string) => {
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
@@ -1145,6 +1401,10 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
monthlySalary: form.monthlySalary, gender: form.gender,
idCardNumber: form.idCardNumber || undefined,
phone: form.phone || undefined,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
housingFundStartMonth: form.housingFundStartMonth || undefined,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
@@ -1186,6 +1446,28 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-2 gap-3">
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
</div>
</div>
</div>
<div className="border-t pt-3">
<Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any, endDate: e.target.value === 'UNFIXED' ? '' : form.endDate })}>
+418 -273
View File
@@ -1,22 +1,24 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History } from 'lucide-react'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { Input, Label, Select } from '../components/ui/Input'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function SocialInsurance() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
const [base, setBase] = useState(8000)
const [showNewVersion, setShowNewVersion] = useState(false)
const [showVersions, setShowVersions] = useState(false)
const [showAdjust, setShowAdjust] = useState(false)
const [adjustData, setAdjustData] = useState<any>(null)
const [editItems, setEditItems] = useState<Record<string, { socialBase: number; housingBase: number }>>({})
const [editItems, setEditItems] = useState<Record<string, number>>({})
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
const [newVersion, setNewVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
@@ -24,6 +26,11 @@ export default function SocialInsurance() {
medicalOrg: 9.8, medicalEmp: 2,
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
injuryOrg: 0.2, maternityOrg: 0.8,
baseMin: 6326, baseMax: 33891,
})
const [newHousingVersion, setNewHousingVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
})
@@ -36,13 +43,42 @@ export default function SocialInsurance() {
},
})
const { data: housingConfig } = useQuery<any>({
queryKey: ['housing-config'],
queryFn: async () => {
const res = await api.get('/social/housing-config') as any
return res.data
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions'],
queryFn: async () => {
const res = await api.get('/social/config/versions') as any
return res.data
},
enabled: showVersions,
enabled: showVersions && tab === 'social',
})
const { data: housingVersions } = useQuery<any[]>({
queryKey: ['housing-config-versions'],
queryFn: async () => {
const res = await api.get('/social/housing-config/versions') as any
return res.data
},
enabled: showVersions && tab === 'housing',
})
const { data: monthlyChanges } = useQuery<any>({
queryKey: ['monthly-changes', monthlyMonth],
queryFn: async () => {
const [socialRes, housingRes] = await Promise.all([
api.get('/social/monthly-changes', { params: { month: monthlyMonth } }) as any,
api.get('/social/housing/monthly-changes', { params: { month: monthlyMonth } }) as any,
])
return { social: socialRes.data, housing: housingRes.data }
},
enabled: tab === 'monthly',
})
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
@@ -52,6 +88,13 @@ export default function SocialInsurance() {
},
})
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/housing-calculate', { base }) as any
return res.data
},
})
const createVersionMutation = useMutation({
mutationFn: (data: any) => api.post('/social/config/versions', data),
onSuccess: () => {
@@ -62,6 +105,16 @@ export default function SocialInsurance() {
},
})
const createHousingVersionMutation = useMutation({
mutationFn: (data: any) => api.post('/social/housing-config/versions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
setShowNewVersion(false)
alert('公积金新版本已创建,旧版本已自动归档')
},
})
const previewAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
@@ -73,8 +126,19 @@ export default function SocialInsurance() {
},
})
const previewHousingAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/housing-config/${housingConfig?.id}/adjust-preview`) as any
return res.data
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const applyAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newSocialBase: number; newHousingBase: number }[] }) =>
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
api.post(`/social/config/${config?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
@@ -82,112 +146,151 @@ export default function SocialInsurance() {
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保/公积金基数`)
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
},
})
const applyHousingAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
api.post(`/social/housing-config/${housingConfig?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
},
})
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 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)
const a = document.createElement('a')
a.href = url
a.download = `${type === 'social' ? '社保' : '公积金'}_${data.month || monthlyMonth}.csv`
a.click()
URL.revokeObjectURL(url)
}
const isHousing = tab === 'housing'
const activeConfig = isHousing ? housingConfig : config
const activeVersions = isHousing ? housingVersions : versions
const activePreviewMut = isHousing ? previewHousingAdjustMutation : previewAdjustMutation
const activeApplyMut = isHousing ? applyHousingAdjustMutation : applyAdjustMutation
const activeCreateMut = isHousing ? createHousingVersionMutation : createVersionMutation
const activeNewVersion = isHousing ? newHousingVersion : newVersion
const activeSetNewVersion = isHousing ? setNewHousingVersion : setNewVersion
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h1 className="text-xs font-medium"></h1>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
{showVersions ? '收起历史' : '版本历史'}
</Button>
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
{tab !== 'monthly' && (
<>
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
{showVersions ? '收起历史' : '版本历史'}
</Button>
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</>
)}
</div>
</div>
{/* 当前生效版本信息 */}
{config && (
{/* Tab 切换 */}
<div className="flex gap-1 border-b">
{(['social', 'housing', 'monthly'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}) }}
>
{t === 'social' ? '社保' : t === 'housing' ? '公积金' : '月度增减员'}
</button>
))}
</div>
{/* ========== 社保 / 公积金 Tab ========== */}
{tab !== 'monthly' && activeConfig && (
<Card>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
<span className="text-xs text-gray-500">{config.effectiveFrom}</span>
<span className="text-xs text-gray-500">· {config.city}</span>
{config.adjustmentDone && (
<span className="text-xs text-gray-500">{activeConfig.effectiveFrom}</span>
<span className="text-xs text-gray-500">· {activeConfig.city}</span>
{activeConfig.adjustmentDone && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-400"></span>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => previewAdjustMutation.mutate()}
disabled={config.adjustmentDone || previewAdjustMutation.isPending}
onClick={() => activePreviewMut.mutate()}
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{config.adjustmentDone ? '已调整' : previewAdjustMutation.isPending ? '加载中...' : '调整员工基数'}
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
</Button>
</div>
<div className="grid md:grid-cols-4 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(config.baseMin)}</span>
{isHousing ? (
<div className="grid md:grid-cols-4 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(config.baseMax)}</span>
) : (
<div className="grid md:grid-cols-4 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.pensionOrg}% / {config.pensionEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.medicalOrg}% / {config.medicalEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.unemploymentOrg}% / {config.unemploymentEmp}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">()</span>
<span className="font-medium">{config.injuryOrg}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">()</span>
<span className="font-medium">{config.maternityOrg}%</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">(/)</span>
<span className="font-medium">{config.housingOrg}% / {config.housingEmp}%</span>
</div>
</div>
)}
</Card>
)}
{/* 调整预览 */}
{showAdjust && adjustData && (
{tab !== 'monthly' && showAdjust && adjustData && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
<SettingsIcon className="w-4 h-4" />{isHousing ? '公积金' : '社保'}
</h3>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)}/
¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)}{isHousing ? '公积金' : '社保'}
=
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, { socialBase: number; housingBase: number }> = {}
adjustData.items.forEach((i: any) => {
newEdits[i.employeeId] = { socialBase: i.suggestedSocialBase, housingBase: i.suggestedHousingBase }
})
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
setEditItems(newEdits)
}}>
<Check className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, { socialBase: number; housingBase: number }> = {}
adjustData.items.forEach((i: any) => {
newEdits[i.employeeId] = { socialBase: i.oldSocialBase, housingBase: i.oldHousingBase }
})
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
setEditItems(newEdits)
}}>
@@ -201,59 +304,27 @@ export default function SocialInsurance() {
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right">()</th>
<th className="py-2 text-right">()</th>
<th className="py-2 text-right">()</th>
<th className="py-2 text-right">()</th>
<th className="py-2 text-right">()</th>
<th className="py-2 text-right">()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
</tr>
</thead>
<tbody>
{adjustData.items.map((item: any) => {
const edit = editItems[item.employeeId]
const socialBase = edit?.socialBase ?? item.suggestedSocialBase
const housingBase = edit?.housingBase ?? item.suggestedHousingBase
const socialChanged = socialBase !== item.oldSocialBase
const housingChanged = housingBase !== item.oldHousingBase
const newBase = edit ?? item.suggestedBase
const changed = newBase !== item.oldBase
return (
<tr key={item.employeeId} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-gray-500">{item.department}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldSocialBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedSocialBase)}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
<td className="py-1.5 text-right">
<Input
type="number"
className="w-24 text-right text-xs"
value={socialBase}
onChange={(e) => setEditItems({
...editItems,
[item.employeeId]: {
socialBase: Number(e.target.value) || 0,
housingBase: edit?.housingBase ?? item.suggestedHousingBase,
},
})}
/>
{socialChanged && <span className="text-warning ml-1"></span>}
</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldHousingBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedHousingBase)}</td>
<td className="py-1.5 text-right">
<Input
type="number"
className="w-24 text-right text-xs"
value={housingBase}
onChange={(e) => setEditItems({
...editItems,
[item.employeeId]: {
socialBase: edit?.socialBase ?? item.suggestedSocialBase,
housingBase: Number(e.target.value) || 0,
},
})}
/>
{housingChanged && <span className="text-warning ml-1"></span>}
<Input type="number" className="w-24 text-right text-xs" value={newBase}
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })} />
{changed && <span className="text-warning ml-1"></span>}
</td>
</tr>
)
@@ -262,34 +333,22 @@ export default function SocialInsurance() {
</table>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
const items = adjustData.items.map((i: any) => {
const edit = editItems[i.employeeId]
return {
employeeId: i.employeeId,
newSocialBase: edit?.socialBase ?? i.suggestedSocialBase,
newHousingBase: edit?.housingBase ?? i.suggestedHousingBase,
}
})
applyAdjustMutation.mutate({ items })
}}
disabled={applyAdjustMutation.isPending}
>
{applyAdjustMutation.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}) }}>
<Button onClick={() => {
const items = adjustData.items.map((i: any) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
activeApplyMut.mutate({ items })
}} disabled={activeApplyMut.isPending}>
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}) }}></Button>
</div>
</Card>
)}
{/* 版本历史 */}
{showVersions && (
{tab !== 'monthly' && showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" /></h3>
{!versions || versions.length === 0 ? (
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
{!activeVersions || activeVersions.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-xs"></div>
) : (
<div className="overflow-x-auto">
@@ -301,28 +360,35 @@ export default function SocialInsurance() {
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right">%</th>
<th className="py-2 text-right">%</th>
<th className="py-2 text-right">%</th>
{isHousing ? (
<th className="py-2 text-right">%</th>
) : (
<>
<th className="py-2 text-right">%</th>
<th className="py-2 text-right">%</th>
</>
)}
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{versions.map((v: any) => (
{activeVersions.map((v: any) => (
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{v.effectiveFrom}</td>
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
<td className="py-2">{v.city}</td>
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
{isHousing ? (
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
) : (
<>
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
</>
)}
<td className="py-2 text-center">
{v.isCurrent
? <span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span>
: <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400"></span>
}
{v.isCurrent ? <span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span> : <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400"></span>}
</td>
</tr>
))}
@@ -334,80 +400,40 @@ export default function SocialInsurance() {
)}
{/* 新建版本 */}
{showNewVersion && (
{tab !== 'monthly' && showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" /></h3>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
7
</div>
<div>7</div>
</div>
<div className="grid md:grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="month" value={newVersion.effectiveFrom} onChange={(e) => setNewVersion({ ...newVersion, effectiveFrom: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={newVersion.city} onChange={(e) => setNewVersion({ ...newVersion, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={newVersion.baseMin} onChange={(e) => setNewVersion({ ...newVersion, baseMin: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={newVersion.baseMax} onChange={(e) => setNewVersion({ ...newVersion, baseMax: Number(e.target.value) })} />
</div>
<div><Label></Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
<div><Label></Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
</div>
<div className="grid md:grid-cols-4 gap-3">
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.pensionOrg} onChange={(e) => setNewVersion({ ...newVersion, pensionOrg: Number(e.target.value) })} />
{isHousing ? (
<div className="grid md:grid-cols-2 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.pensionEmp} onChange={(e) => setNewVersion({ ...newVersion, pensionEmp: Number(e.target.value) })} />
) : (
<div className="grid md:grid-cols-4 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.injuryOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.medicalOrg} onChange={(e) => setNewVersion({ ...newVersion, medicalOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.medicalEmp} onChange={(e) => setNewVersion({ ...newVersion, medicalEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.unemploymentOrg} onChange={(e) => setNewVersion({ ...newVersion, unemploymentOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.unemploymentEmp} onChange={(e) => setNewVersion({ ...newVersion, unemploymentEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.injuryOrg} onChange={(e) => setNewVersion({ ...newVersion, injuryOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.maternityOrg} onChange={(e) => setNewVersion({ ...newVersion, maternityOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.housingOrg} onChange={(e) => setNewVersion({ ...newVersion, housingOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" step="0.1" value={newVersion.housingEmp} onChange={(e) => setNewVersion({ ...newVersion, housingEmp: Number(e.target.value) })} />
</div>
</div>
)}
<div className="flex gap-2">
<Button onClick={() => createVersionMutation.mutate(newVersion)} disabled={createVersionMutation.isPending}>
{createVersionMutation.isPending ? '保存中...' : '创建版本'}
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
</Button>
<Button variant="secondary" onClick={() => setShowNewVersion(false)}></Button>
</div>
@@ -416,82 +442,201 @@ export default function SocialInsurance() {
)}
{/* 试算工具 */}
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="text-xs font-medium mb-3"></h2>
<div className="space-y-3">
<div>
<Label></Label>
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
</div>
<Button onClick={() => calcMutate()} disabled={isPending}>
<Calculator className="w-4 h-4 mr-1" />
{isPending ? '计算中...' : '开始计算'}
</Button>
{config && (
<div className="text-xs text-gray-400">
{config.city} | {fmt(config.baseMin)}~{fmt(config.baseMax)}
</div>
)}
</div>
</Card>
<Card>
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" /></h2>
{result ? (
{tab !== 'monthly' && (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="text-xs font-medium mb-3">{isHousing ? '公积金' : '社保'}</h2>
<div className="space-y-3">
<div className="text-xs text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(result.actualBase)}</span>
{result.capped && <span className="text-warning ml-2"></span>}
{result.floored && <span className="text-warning ml-2"></span>}
{result.configVersion && <span className="text-gray-400 ml-2">| {result.configVersion}</span>}
<div>
<Label></Label>
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-1.5"></th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right"></th>
<th className="py-1.5 text-right"></th>
</tr>
</thead>
<tbody>
{result.items.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-bold">
<td className="py-2" colSpan={3}></td>
<td className="py-2 text-right text-danger">¥{fmt(result.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(result.totalEmp)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-primary">¥{fmt(result.total)}</span>
<Button onClick={() => isHousing ? calcHousingMutate() : calcMutate()} disabled={isHousing ? housingCalcPending : isPending}>
<Calculator className="w-4 h-4 mr-1" />
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
</Button>
{activeConfig && (
<div className="text-xs text-gray-400">
{activeConfig.city} | {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(result.totalOrg)} + ¥{fmt(result.totalEmp)}
</div>
</div>
)}
</div>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</Card>
<Card>
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" /></h2>
{(() => {
const r = isHousing ? housingResult : result
if (!r) return <div className="text-gray-400 text-xs"></div>
return (
<div className="space-y-3">
<div className="text-xs text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
{r.capped && <span className="text-warning ml-2"></span>}
{r.floored && <span className="text-warning ml-2"></span>}
{r.configVersion && <span className="text-gray-400 ml-2">| {r.configVersion}</span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-1.5"></th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right"></th>
<th className="py-1.5 text-right"></th>
</tr>
</thead>
<tbody>
{r.items.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-bold">
<td className="py-2" colSpan={3}></td>
<td className="py-2 text-right text-danger">¥{fmt(r.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(r.totalEmp)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(r.totalOrg)} + ¥{fmt(r.totalEmp)}
</div>
</div>
</div>
)
})()}
</Card>
</div>
)}
{/* ========== 月度增减员 Tab ========== */}
{tab === 'monthly' && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-medium"></h2>
<div className="flex items-center gap-2">
<Input type="month" value={monthlyMonth} onChange={(e) => setMonthlyMonth(e.target.value)} className="!w-32" />
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('social', monthlyChanges.social)}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('housing', monthlyChanges.housing)}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md mb-3">
//
</div>
{(() => {
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-xs">...</div>
const sAdd = monthlyChanges.social?.additions || []
const sSub = monthlyChanges.social?.subtractions || []
const hAdd = monthlyChanges.housing?.additions || []
const hSub = monthlyChanges.housing?.subtractions || []
if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0) {
return <div className="text-center py-4 text-gray-400 text-xs">{monthlyMonth} </div>
}
return (
<div className="space-y-4">
{/* 社保增减员 */}
<div>
<h3 className="text-xs font-medium mb-2"></h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{sAdd.map((i: any) => (
<tr key={`sa-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5">{i.startMonth}</td>
<td className="py-1.5 text-gray-400"></td>
</tr>
))}
{sSub.map((i: any) => (
<tr key={`ss-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-gray-400"></td>
<td className="py-1.5">{i.endMonth}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 公积金增减员 */}
<div>
<h3 className="text-xs font-medium mb-2"></h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{hAdd.map((i: any) => (
<tr key={`ha-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5">{i.startMonth}</td>
<td className="py-1.5 text-gray-400"></td>
</tr>
))}
{hSub.map((i: any) => (
<tr key={`hs-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-gray-400"></td>
<td className="py-1.5">{i.endMonth}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
})()}
</Card>
</div>
)}
<p className="text-xs text-gray-400">
/7
+26
View File
@@ -56,6 +56,8 @@ export default function Termination() {
const [reason, setReason] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [terminationDate, setTerminationDate] = useState('')
const [socialInsEndMonth, setSocialInsEndMonth] = useState('')
const [housingFundEndMonth, setHousingFundEndMonth] = useState('')
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
const [socialAvgWage, setSocialAvgWage] = useState(0)
@@ -289,6 +291,8 @@ export default function Termination() {
employeeId,
reason,
terminationDate: new Date(terminationDate).toISOString(),
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
compensation: costResult?.totalSeverance || 0,
checklist,
remark: '',
@@ -394,6 +398,8 @@ export default function Termination() {
setReason('')
setEmployeeId('')
setTerminationDate('')
setSocialInsEndMonth('')
setHousingFundEndMonth('')
setChecklist({})
setAcknowledgeRisk(false)
}
@@ -523,6 +529,26 @@ export default function Termination() {
<Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="month" value={socialInsEndMonth || terminationDate.slice(0, 7)} onChange={(e) => setSocialInsEndMonth(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="month" value={housingFundEndMonth || terminationDate.slice(0, 7)} onChange={(e) => setHousingFundEndMonth(e.target.value)} />
</div>
</div>
{terminationDate && ((socialInsEndMonth && socialInsEndMonth !== terminationDate.slice(0, 7)) || (housingFundEndMonth && housingFundEndMonth !== terminationDate.slice(0, 7))) && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
/
</div>
)}
</div>
{/* 禁止解聘检查 */}
{riskAssessment && riskAssessment.warnings.length > 0 && (