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:
@@ -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-MM(null=至今有效)
|
||||
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-MM(null=至今有效)
|
||||
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-MM(null=至今有效)
|
||||
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:离职/解聘填写社保公积金截止年月
|
||||
|
||||
### 前端 ResignModal(Roster.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 推送 | 全部 |
|
||||
@@ -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-MM(null=当前有效)
|
||||
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-MM(null=当前有效)
|
||||
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-MM(null=至今有效)
|
||||
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-MM(null=至今有效)
|
||||
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-MM(null=至今有效)
|
||||
reason String? // 调部门原因
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, effectiveMonth, endMonth])
|
||||
}
|
||||
|
||||
model OnboardingLink {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
// 风险提示
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 个税:优先用归档批次的实际计算值,否则估算
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 })}>
|
||||
|
||||
@@ -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月调整。
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user