feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)

P0紧急修复(6项):
- 草稿保存完整恢复所有字段(含socialAvgWage)
- 补偿金批次从compensationBreakdown读取
- 违法解除风险确认UI
- 合同结束日期前后校验(前后端双保险)

P1高优先级(14项):
- 离职日期联动社保/公积金截止月(15号规则)
- 合规检查+工作交接改为软阻断(生成待办)
- 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义)
- 解聘并入花名册操作栏(类型选择跳转向导)
- 合同续签开始日期自动推导(原合同结束日+1天)
- 年龄合规筛查(童工阻断/未成年工/退休警告)
- 编辑入职日期后状态联动(待入职↔在职)
- 转正移植到花名册操作栏+薪资回写
- 男职工无法选择三期

P2体验优化(9项):
- "劳动合同"调整为"用工关系"
- 费用结算新增剩余年假折算(300%日工资)
- 身份证号全域改为"证件号码"(前后端18个文件)
- 手机号查重
- 开具证明+合同续签移植到花名册操作栏
- 批量转正+批量开具证明
- 去掉用工办理模块

P3规划(2项):
- 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance)
- 客服工作台(Ticket/ChatSession+SUPPORT角色)

新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage
新增字段: Employee.departmentId/supervisorId
新增角色: SUPPORT

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 12:37:27 +08:00
parent 8cfdd566af
commit e1b5ae9aab
46 changed files with 3455 additions and 206 deletions
+32
View File
@@ -0,0 +1,32 @@
序号,类型,页面,功能点,需调整项目,字典表,逻辑,备注
1,调整,,,“劳动合同”,是否应调整为更为宽泛的用工关系,,
2,调整,离职管理,,“离职日期”,,办理离职的离职日期调整后,应影响社保截止年月和公积金截止年月(例如15日之前离职不缴纳当月社保、15日之后离职缴纳当月社保)可配置,
3,调整,离职管理,合规检查,“是否已支付经济补偿金”,,不勾选无法进行下一步,应增加保存后未办事项进入代办和风险提醒中,
4,新增,离职管理,费用结算,“剩余年假天数”,,调用或录入,影响离职日期计算,
5,调整,离职管理,费用结算,“补偿月数”,N、N+1、2N、其他,选择协商解除时可填写修改并重新运算补偿金,
6,调整,离职管理,费用结算,“计算基数”,近12月平均工资、合同工资、其他,可调整,按照选用算法计算,其他需手动填写,
7,调整,离职管理,费用结算,“手动调整补偿金分项”,,调整后合计应付未同步更新,
8,调整,离职管理,工作交接,勾选的内容应为待办事项,前三项为离职必须要执行的任务,,未到离职日期,工作交接未完成,无法进行下一步操作,应调整为可进行下一步,未完成事项进入代办提醒,
9,调整,离职管理,,将离职管理模块中的“新建解聘”功能并入到花名册员工列表的操作栏中的离职功能,,,
10,调整,离职管理,,“解聘驳回”,,解聘驳回后,重新修改了解聘类型、金额等,系统未按照新内容更新,
11,调整,离职管理,,“新增解聘”,,选择了违法解除后(2N),修改降低了经济补偿金金额,合规提醒并未风险提示可继续进行下一步操作,需要增加合规风险提醒,
12,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同开始日期应为上一份合同结束日期+1(例如第一份合同结束日期为2023年5月31日,那么新增合同后合同开始日期应为2026年6月1日),
13,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同结束日期早于合同开始日期,系统未校验判断产生了时间倒置,
14,调整,花名册,添加员工,“身份证号”,,全域修改为“证件号码”,
15,调整,花名册,添加员工,年龄限制,,添加员工时根据证件号码对员工年龄进行合规性筛查,
16,调整,花名册,员工明细编辑,编辑修改“入职日期”,,花名册明细中的状态未变更(例如从今天变更为下月1日入职,状态应变更为待入职),
17,调整,花名册,添加员工,“手机号”,,录入手机号时进行查重,
18,调整,用工办理,员工转正,将用工办理模块中的“员工转正”功能移植到花名册员工列表的操作栏中,替换发薪功能,,,
19,调整,用工办理,员工转正,“员工转正”,,填写了转正薪资,在员工花名册里未体现变更后内容,是否与已签订的用工协议匹配需要验证,如不一致应发起合同变更流程,
20,调整,用工办理,开具证明,将用工办理模块中的“开具证明”功能移植到花名册员工列表的操作栏中,,,
21,调整,用工办理,合同续签,将用工办理模块中的“合同续签”功能移植到花名册员工列表的操作栏中,,,
22,调整,用工办理,合同变更,“合同变更”,,选择员工无法查询到对应的合同,
23,调整,用工办理,合同续签,“合同续签”,,原合同ID无法查询,建议修改为选择该员工已存在的合同,
24,调整,花名册,,勾选多名员工时,增加批量转正功能,,,
25,调整,花名册,,勾选多名员工时,增加批量开具证明功能,,,
26,调整,用工办理,,去掉用工办理模块,,,
27,调整,系统设置,,增加简单的组织架构,用于满足三步以内的审批流转,,,
28,调整,薪税管理,创建发薪批次,补偿金批次,,批次类型为补偿金批次的,经济补偿金发放数据读取错误,
29,调整,,特殊员工,“特殊员工”,,男职工应无法选择三期,
30,BUG,全域,,全域保存草稿后部分录入的数据未保存,,全域保存草稿后部分录入的数据未保存,
31,调整,,,单立户客服端,,,
1 序号 类型 页面 功能点 需调整项目 字典表 逻辑 备注
2 1 调整 “劳动合同” 是否应调整为更为宽泛的用工关系
3 2 调整 离职管理 “离职日期” 办理离职的离职日期调整后,应影响社保截止年月和公积金截止年月(例如15日之前离职不缴纳当月社保、15日之后离职缴纳当月社保)可配置
4 3 调整 离职管理 合规检查 “是否已支付经济补偿金” 不勾选无法进行下一步,应增加保存后未办事项进入代办和风险提醒中
5 4 新增 离职管理 费用结算 “剩余年假天数” 调用或录入,影响离职日期计算
6 5 调整 离职管理 费用结算 “补偿月数” N、N+1、2N、其他 选择协商解除时可填写修改并重新运算补偿金
7 6 调整 离职管理 费用结算 “计算基数” 近12月平均工资、合同工资、其他 可调整,按照选用算法计算,其他需手动填写
8 7 调整 离职管理 费用结算 “手动调整补偿金分项” 调整后合计应付未同步更新
9 8 调整 离职管理 工作交接 勾选的内容应为待办事项,前三项为离职必须要执行的任务 未到离职日期,工作交接未完成,无法进行下一步操作,应调整为可进行下一步,未完成事项进入代办提醒
10 9 调整 离职管理 将离职管理模块中的“新建解聘”功能并入到花名册员工列表的操作栏中的离职功能
11 10 调整 离职管理 “解聘驳回” 解聘驳回后,重新修改了解聘类型、金额等,系统未按照新内容更新
12 11 调整 离职管理 “新增解聘” 选择了违法解除后(2N),修改降低了经济补偿金金额,合规提醒并未风险提示可继续进行下一步操作,需要增加合规风险提醒
13 12 调整 用工办理 合同续签 “新增合同” 续签新增合同,合同开始日期应为上一份合同结束日期+1(例如第一份合同结束日期为2023年5月31日,那么新增合同后合同开始日期应为2026年6月1日)
14 13 调整 用工办理 合同续签 “新增合同” 续签新增合同,合同结束日期早于合同开始日期,系统未校验判断产生了时间倒置
15 14 调整 花名册 添加员工 “身份证号” 全域修改为“证件号码”
16 15 调整 花名册 添加员工 年龄限制 添加员工时根据证件号码对员工年龄进行合规性筛查
17 16 调整 花名册 员工明细编辑 编辑修改“入职日期” 花名册明细中的状态未变更(例如从今天变更为下月1日入职,状态应变更为待入职)
18 17 调整 花名册 添加员工 “手机号” 录入手机号时进行查重
19 18 调整 用工办理 员工转正 将用工办理模块中的“员工转正”功能移植到花名册员工列表的操作栏中,替换发薪功能
20 19 调整 用工办理 员工转正 “员工转正” 填写了转正薪资,在员工花名册里未体现变更后内容,是否与已签订的用工协议匹配需要验证,如不一致应发起合同变更流程
21 20 调整 用工办理 开具证明 将用工办理模块中的“开具证明”功能移植到花名册员工列表的操作栏中
22 21 调整 用工办理 合同续签 将用工办理模块中的“合同续签”功能移植到花名册员工列表的操作栏中
23 22 调整 用工办理 合同变更 “合同变更” 选择员工无法查询到对应的合同
24 23 调整 用工办理 合同续签 “合同续签” 原合同ID无法查询,建议修改为选择该员工已存在的合同
25 24 调整 花名册 勾选多名员工时,增加批量转正功能
26 25 调整 花名册 勾选多名员工时,增加批量开具证明功能
27 26 调整 用工办理 去掉用工办理模块
28 27 调整 系统设置 增加简单的组织架构,用于满足三步以内的审批流转
29 28 调整 薪税管理 创建发薪批次 补偿金批次 批次类型为补偿金批次的,经济补偿金发放数据读取错误
30 29 调整 特殊员工 “特殊员工” 男职工应无法选择三期
31 30 BUG 全域 全域保存草稿后部分录入的数据未保存 全域保存草稿后部分录入的数据未保存
32 31 调整 单立户客服端
+384
View File
@@ -0,0 +1,384 @@
# 20260815 优化清单
> 来源:`20260815-优化.csv`(共 31 条)
> 结合 TurboHR 当期功能梳理,按模块归类,标注根因 / 修复方向 / 优先级 / 涉及文件。
> 优先级:P0 紧急(影响业务正确性/数据丢失)|P1 高(流程阻塞或合规风险)|P2 中(体验/增强)|P3 规划(新模块)
---
## 一、离职管理(问题 2-11
### 问题 2:离职日期调整后未联动社保/公积金截止年月
- **现状**`Termination.tsx:1334-1338` 社保截止、公积金截止为 `<Input type="month">` 手动填写,默认值取 `terminationDate.slice(0,7)`,但修改离职日期后不会重算,也无"15 日前/后"规则。
- **修复方向**
1. 增加"离职日期 → 社保/公积金截止月"自动推导规则:每月 15 日(含)前离职不缴当月、15 日后离职缴纳当月,截止月 = 离职月 - 0 或 -1。
2. 阈值日期(15 日)做成系统设置可配置项(`org_settings.socialInsCutoffDay`,默认 15)。
3. 离职日期变更时自动回填两个截止月,同时保留手动覆盖入口。
- **优先级**P1 高
- **涉及文件**`frontend/src/pages/Termination.tsx``backend/src/services/termination.service.ts``backend/prisma/schema.prisma`org_settings 增字段)
### 问题 3:合规检查"是否已支付经济补偿金"硬阻断
- **现状**:合规检查为必勾才能进入下一步,未勾选时无法继续,且未保存的检查项不进入待办/风险提醒。
- **修复方向**
1. 将"必勾才能下一步"改为"未勾选可下一步,但自动写入待办事项 + 风险提醒"。
2. 待办项关联 `termination_draft`,在 Dashboard/风险中心展示"XX 员工解聘未支付经济补偿金"。
3. 提交审批时若仍存在未闭环必检项,给出强提示但不阻断(除非法定禁止情形,如孕期/工伤)。
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 2 校验逻辑)、`backend/src/services/notification.service.ts``frontend/src/pages/Dashboard.tsx`
### 问题 4:费用结算新增"剩余年假天数"
- **现状**:费用结算步骤仅展示补偿金/代通知金/赔偿金,未涉及年假折算。
- **修复方向**
1. 费用结算区新增"剩余年假天数"字段,支持手动录入或调用考勤模块年假余额(若有)。
2. 按规则计算未休年假折算工资:`日工资 × 剩余年假天数 × 300%`(未休部分)。
3. 折算金额计入"合计应付"。
- **优先级**P2 中
- **涉及文件**`Termination.tsx`step 3)、`backend/src/services/termination.service.ts`cost 计算扩展)
### 问题 5:补偿月数支持 N / N+1 / 2N / 其他
- **现状**`Termination.tsx:1442` 补偿月数取 `costResult.cappedMonths`(系统按工龄自动算 N),不允许选择其他模式。
- **修复方向**
1. 补偿月数改为下拉:N、N+1、2N、其他(自定义输入)。
2. 选择"协商解除"时允许修改月数并实时重算补偿金。
3. 选择"其他"时需填写说明,留痕到 `compensationBreakdown.adjustments`
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 3 补偿金区)、`backend/src/services/termination.service.ts`
### 问题 6:计算基数支持多种来源
- **现状**`Termination.tsx:1443` 计算基数固定取 `costResult.cappedWage`(近 12 月平均工资,且有三倍社平工资封顶)。
- **修复方向**
1. 计算基数改为下拉:近 12 月平均工资(默认)、合同工资、其他(手动填写)。
2. 切换来源后实时重算补偿金,并记录选用算法到草稿。
3. "其他"需填写说明,受三倍社平封顶提示但不强制。
- **优先级**P1 高
- **涉及文件**`Termination.tsx``termination.service.ts`
### 问题 7:手动调整补偿金分项后合计未同步
- **现状**`Termination.tsx:530` 注释提到"系统预估 + 手动调整差额",但 `handleSave`578 行附近)仍使用 `costResult.grandTotal`,未叠加 `compAdjustments`。20260803 问题 13 已识别但未闭环。
- **修复方向**
1. 实际合计 = `costResult.grandTotal + Σ(adjustments.to - adjustments.from)`,保存与确认页统一使用此值。
2. 调整任一分项后实时刷新"合计应付"显示。
3. 后端 `compensationBreakdown.adjustments` 已支持,前端需正确传递并回显。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx``handleSave`、确认步骤、step 3 合计显示)
### 问题 8:工作交接未完成无法下一步
- **现状**`Termination.tsx:552-554` step 4 校验 `work_handover/equipment_return/access_revoke` 三项必须完成才能下一步。
- **修复方向**
1. 改为可下一步,未完成事项自动写入待办(关联离职草稿)。
2. 交接清单前三项标注"离职必办",未完成时在确认页和风险中心强提示。
3. 待办在到达离职日期前持续提醒,超期升级为风险。
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 4 校验)、`notification.service.ts``Dashboard.tsx`
### 问题 9:离职管理"新建解聘"并入花名册操作栏
- **现状**:花名册操作栏已有"离职"按钮(`Roster.tsx:734-746`,触发 `ResignModal` 创建 RESIGNATION 草稿),但"新建解聘"仍在离职管理列表页独立入口,且仅支持 RESIGNATION 类型。
- **修复方向**
1. 花名册操作栏"离职"按钮扩展为支持所有解聘类型(协商/过错/非过错/裁员/到期/违法),弹出类型选择后跳转 Termination 向导并预填员工。
2. 离职管理列表页移除"新建解聘"按钮,仅保留草稿列表与审批。
- **优先级**P1 高
- **涉及文件**`Roster.tsx``roster/modals.tsx``ResignModal`)、`Termination.tsx`(支持 URL 参数预填)
### 问题 10:解聘驳回后修改内容未更新
- **现状**:驳回后重新修改解聘类型、金额等,系统未按新内容更新(疑似使用旧草稿快照或前端未刷新)。
- **修复方向**
1. 排查 `updateDraft` 是否正确覆盖 `reason/compensationBreakdown/terminationDate`
2. 驳回后再编辑走 `updateDraft`(而非新建),版本号 +1,旧版本留快照。
3. 前端进入驳回草稿时强制重新拉取最新数据。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx`(编辑驳回草稿逻辑)、`backend/src/services/termination.service.ts``backend/src/routes/termination.routes.ts`
### 问题 11:违法解除降低补偿金后无合规风险提醒
- **现状**:选择 ILLEGAL(2N)后手动降低补偿金金额,`acknowledgeRisk` 仅在初始选择违法解除时提示,金额变动后不再校验。
- **修复方向**
1. 实际补偿金 < 法定 2N 时,强制弹出合规风险提醒,需勾选"已知风险并继续"才能下一步。
2. 风险提醒记录到 `compensationBreakdown.riskAcknowledged`,留痕可审计。
3. 同步推送风险中心。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx`step 3 风险校验)、`risk-center`
---
## 二、用工办理 / 合同(问题 12-13、18、20-23、26
### 问题 12:合同续签新增合同开始日期应为上一份结束日期 +1
- **现状**`WorkProcess.tsx:93-99` RENEW 表单 `newStartDate` 为手动填写,无自动推导。
- **修复方向**
1. 选择员工后自动拉取最新已签合同,`newStartDate` 默认 = 上一份合同 `endDate + 1 天`
2. 允许手动覆盖,但偏离时提示。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`RENEW 字段)、`backend/src/services/work-process.service.ts`
### 问题 13:合同结束日期早于开始日期未校验
- **现状**`WorkProcess.tsx` 表单提交前无日期前后关系校验,已产生时间倒置数据。
- **修复方向**
1. 前端表单提交前校验 `endDate >= startDate`,否则阻断并提示。
2. 后端 `work-process.service.ts` 增加同样校验,双保险。
3. 历史倒置数据提供修复脚本/列表提示。
- **优先级**P0 紧急
- **涉及文件**`WorkProcess.tsx``work-process.service.ts`
### 问题 18:员工转正移植到花名册操作栏,替换发薪
- **现状**:花名册操作栏(`Roster.tsx:694-747`)有调薪/发薪/调部门/离职,无转正入口;转正仍在用工办理 CONFIRM 流程。
- **修复方向**
1. 操作栏"发薪"按钮(`Wallet`711-720 行)替换为"转正"按钮(`CheckCircle`),点击弹出转正 Modal(转正日期 + 转正薪资)。
2. 仅试用期员工(`probationInfo.isProbation`)显示转正按钮。
3. 发薪入口保留在薪税管理模块。
- **优先级**P1 高
- **涉及文件**`Roster.tsx``roster/modals.tsx`(新增 `ConfirmModal`
### 问题 19:转正薪资未回写花名册,未与用工协议匹配校验
- **现状**CONFIRM 流程填写 `regularSalary` 后未同步到员工 `monthlySalary`,也未校验与已签合同薪资是否一致。
- **修复方向**
1. 转正完成后同步更新员工 `monthlySalary`,并在花名册明细展示"试用期薪资 → 转正薪资"变更记录。
2. 校验转正薪资与最新合同 `monthlySalary` 是否一致,不一致时提示并支持发起合同变更流程(CHANGE)。
- **优先级**P1 高
- **涉及文件**`work-process.service.ts`CONFIRM 完成回调)、`Roster.tsx``roster/SalaryInfo.tsx`
### 问题 20:开具证明移植到花名册操作栏
- **现状**:操作栏无开具证明入口,需进用工办理 INCOME_CERT/LEAVING_CERT 流程。
- **修复方向**:操作栏新增"证明"按钮(`FileText`),点击弹出证明类型选择(收入证明/离职证明/在职证明),复用 WorkProcess 的 INCOME_CERT/LEAVING_CERT 表单。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``roster/modals.tsx`
### 问题 21:合同续签移植到花名册操作栏
- **现状**:操作栏无续签入口,批量续签在列表顶部(`Roster.tsx:444`),单人续签需进用工办理。
- **修复方向**:操作栏新增"续签"按钮(`Repeat`),仅合同即将到期(≤30 天)或已到期员工显示,点击跳转 WorkProcess RENEW 并预填员工。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``WorkProcess.tsx`(支持 URL 参数预填)
### 问题 22:合同变更选择员工无法查询到对应合同
- **现状**`WorkProcess.tsx:88-92` CHANGE 表单 `contractId``contract-select` 类型,但选择员工后未按员工过滤合同列表,导致查不到。
- **修复方向**
1. `contract-select` 组件接收 `employeeId` 参数,仅返回该员工的有效合同。
2. 后端合同查询接口支持 `employeeId` 过滤。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`contract-select 组件)、`backend/src/routes/contract.routes.ts`
### 问题 23:合同续签原合同 ID 无法查询,改为选择已存在合同
- **现状**`WorkProcess.tsx:95` RENEW 表单 `oldContractId``text` 类型,需手动输入 UUID,无法查询。
- **修复方向**:将 `oldContractId` 改为 `contract-select`(同问题 22),选择员工后下拉展示该员工已存在合同。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`RENEW 字段类型)
### 问题 26:去掉用工办理模块
- **现状**:侧边栏"团队"分组有"用工办理"入口(`SidebarNav.tsx:47`),承载 HIRE/ONBOARD/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/LEAVING_CERT/FLEXIBLE 等流程。
- **修复方向**
1. 将各流程入口下沉到花名册操作栏(转正/续签/变更/开具证明/入职)和独立页面(合同管理/电子签署)。
2. 侧边栏移除"用工办理"菜单项,保留 WorkProcess 页面作为批量流程入口(或迁移到"更多"分组)。
3. 需先完成问题 18/20/21/22/23 的入口下沉,再移除菜单。
- **优先级**:P2 中(依赖前置问题完成)
- **涉及文件**`SidebarNav.tsx``Roster.tsx``WorkProcess.tsx``App.tsx`(路由)
---
## 三、花名册(问题 14-17、24-25
### 问题 14:身份证号全域改为"证件号码"
- **现状**`roster/modals.tsx:687``WorkProcess.tsx:60/109/132/140` 等多处 label 为"身份证号"。
- **修复方向**:全域搜索替换 label "身份证号" → "证件号码",字段名 `idCardNumber` 保持不变(兼容历史数据),同时支持证件类型下拉(身份证/护照/港澳台通行证)。
- **优先级**P2 中
- **涉及文件**`roster/modals.tsx``WorkProcess.tsx``Roster.tsx`(搜索 placeholder)、`SpecialStatus.tsx`
### 问题 15:添加员工根据证件号码进行年龄合规筛查
- **现状**`roster/modals.tsx:557` 已根据身份证号自动计算性别,未做年龄校验。
- **修复方向**
1. 解析证件号码出生日期,计算年龄。
2. 年龄 < 16 岁禁止录入(童工红线),强提示并阻断。
3. 年龄 ≥ 60(男)/55(女干部)/50(女工人)提示已达法定退休年龄,确认后允许录入但标记"退休返聘"。
- **优先级**P1 高
- **涉及文件**`roster/modals.tsx``handleIdCardChange`
### 问题 16:编辑入职日期后状态未变更
- **现状**:花名册明细编辑入职日期后,员工状态未联动(如改为下月 1 日入职,状态应从"在职"变为"待入职")。
- **修复方向**
1. 编辑入职日期时,若新入职日期 > 今天,状态自动设为 `PENDING_ONBOARD`(待入职);若 ≤ 今天,设为 `ACTIVE`
2. 状态变更需二次确认,避免误操作。
3. 变更记录留痕到员工档案。
- **优先级**P1 高
- **涉及文件**`roster/BasicInfo.tsx``backend/src/services/employee.service.ts`
### 问题 17:录入手机号查重
- **现状**`roster/modals.tsx:702` 手机号为选填,无查重;身份证号已有查重(691 行)。
- **修复方向**
1. 手机号录入时调用查重接口,已存在则提示"该手机号已用于 XX 人员"。
2. 允许继续保存(一人多号/家庭号场景),但强提示确认。
- **优先级**P2 中
- **涉及文件**`roster/modals.tsx``backend/src/routes/employee.routes.ts`(新增手机号查重接口)
### 问题 24:花名册多选增加批量转正
- **现状**`Roster.tsx:444-447` 批量操作仅有"批量续签""批量解聘",无批量转正。
- **修复方向**
1. 多选后新增"批量转正"按钮,仅对试用期员工生效。
2. 弹窗统一填写转正日期(默认今天)和转正薪资(可按原薪资倍数/手动逐人填写)。
3. 调用后端批量转正接口,复用问题 18 的转正逻辑。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``roster/modals.tsx``backend/src/services/employee.service.ts`
### 问题 25:花名册多选增加批量开具证明
- **现状**:无批量开具证明入口。
- **修复方向**
1. 多选后新增"批量开具证明"按钮,选择证明类型(在职/收入)后批量生成。
2. 支持批量下载(ZIP)或逐个下载。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``backend/src/services/work-process.service.ts`(批量生成接口)
---
## 四、薪税管理(问题 28
### 问题 28:补偿金批次经济补偿金发放数据读取错误
- **现状**`money/BatchTab.tsx:110` 批次类型支持 `SEVERANCE`(补偿金),但创建补偿金批次时经济补偿金发放数据读取逻辑有误(疑似读取了工资数据或未关联离职草稿的 `compensationBreakdown`)。
- **修复方向**
1. 排查 `createBatch``type=SEVERANCE` 时的数据源,应从已审批通过的 `termination_draft` 读取 `compensationBreakdown.grandTotal`
2. 校验员工范围:仅包含有未发放补偿金的离职员工。
3. 增加预览页展示每位员工的补偿金明细,确认后再创建批次。
- **优先级**P0 紧急
- **涉及文件**`money/BatchTab.tsx``backend/src/services/payroll.service.ts`SEVERANCE 分支)
---
## 五、特殊员工(问题 29
### 问题 29:男职工应无法选择三期
- **现状**`SpecialStatus.tsx:123` 默认 `type: 'PREGNANCY'`,新增/编辑表单未根据员工性别过滤类型,男职工也可选"三期"。
- **修复方向**
1. 选择员工后,若 `gender === '男'`,类型下拉移除"三期"选项或置灰并提示"三期仅适用于女性员工"。
2. 后端保存时增加校验,男职工 + PREGNANCY 组合拒绝并返回 400。
- **优先级**P1 高
- **涉及文件**`SpecialStatus.tsx`(表单 type 下拉)、`backend/src/services/special-status.service.ts`
---
## 六、系统设置 / 组织架构(问题 27)
### 问题 27:增加简单组织架构,支持三步以内审批流转
- **现状**:系统无组织架构模块,`LeaveApproval.tsx:90` 仅有简单审批,无层级流转;`settingsApi.org()` 仅返回企业基本信息。当前员工仅有 `department`(字符串)和 `position`(字符串,前端 label "职务/岗位")两个字段,无上下级关系。
- **职位/岗位设计决策**
- **现状**:数据库只有一个 `position` 字段(`schema.prisma:225`,注释"岗位"),前端 label 统一为"职务/岗位"`roster/modals.tsx:686``roster/BasicInfo.tsx:153/328`),已合并为一个字段。
- **建议方案 A(推荐,保留一个字段)**:维持 `position` 单字段,label 保持"职务/岗位"。组织架构中"岗位"作为独立实体管理(岗位字典,含岗位名称、职级、编制人数),员工 `position` 关联到岗位字典。优点:改动小,兼容历史数据,符合当前使用习惯。
- **方案 B(拆分两个字段)**:新增 `jobTitle`(职位,如"经理/主管/专员"+ `position`(岗位,如"前端工程师")。优点:职级体系更清晰;缺点:需改 schema + 全域表单 + 历史 position 数据需清洗归类,工作量大。
- **结论**:建议采用方案 A,组织架构模块中"岗位"独立建表(`Position` 字典),员工 `position` 字段值关联岗位字典 ID 或保持文本(兼容旧数据),审批流按"直属上级 → 部门负责人"两级流转,无需引入职级。
- **修复方向**
1. 系统设置新增"组织架构"子页:树形部门(`Department` 自引用 parent+ 岗位字典(`Position`,含名称/所属部门/编制)+ 上下级关系(`Employee.supervisorId`)。
2. 新建简单审批流配置:最多 3 步(发起人 → 直属上级 → 部门负责人),支持按流程类型(休假/离职/调薪)配置是否启用某步。
3. 现有 `LeaveApproval` 接入审批流引擎,离职/调薪等流程可选启用。
4. 数据模型:`Department`(树形,parent 自引用)、`Position`(岗位字典)、`Employee.supervisorId``ApprovalFlow`type + steps)。
- **优先级**:P3 规划(新模块,建议单独排期)
- **涉及文件**`Settings.tsx`、新增 `pages/OrgChart.tsx``backend/prisma/schema.prisma``backend/src/services/approval.service.ts`
---
## 七、全域问题(问题 1、30、31)
### 问题 1"劳动合同"调整为更宽泛的"用工关系"
- **现状**:系统多处文案使用"劳动合同"(合同管理、离职管理、WorkProcess 等)。
- **修复方向**
1. 全域文案审计:将面向用户的"劳动合同"在合适场景改为"用工关系"或"用工协议"(涵盖劳动合同/劳务协议/实习协议/灵活用工协议)。
2. 数据层 `Contract` 模型保持不变,新增 `contractCategory` 字段区分劳动关系类型。
3. 注意法律文书模板中的"劳动合同"为法定术语,不可改。
- **优先级**P2 中
- **涉及文件**`SidebarNav.tsx``Contracts.tsx``WorkProcess.tsx``Termination.tsx` 等文案
### 问题 30:全域保存草稿后部分录入数据未保存(BUG)
- **现状**:多个流程页面支持"保存草稿",但部分字段(如 `compAdjustments``handoverItems` 备注、`checklistOverrides`)未持久化。
- **修复方向**
1. 排查各流程 `saveDraft` 的 payload,确保覆盖所有前端 state。
2. 后端 `draft` 表 schema 检查是否有字段缺失(`compensationBreakdown.adjustments``handoverItems.remark` 等)。
3. 增加"草稿完整性校验":保存后立即回读对比,缺失字段告警。
4. 重点排查:Termination(补偿金调整/交接备注)、WorkProcess(自定义字段)。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx``handleSaveDraft`)、`WorkProcess.tsx``backend/src/services/termination.service.ts``backend/src/services/work-process.service.ts`
### 问题 31:单立户客服端
#### 已实现部分(平台管理端,代码完整可运行)
系统已有完整的"平台管理端"(Platform),前后端代码均已实现:
**前端(4 页面 + 1 侧边栏)**
- `frontend/src/pages/platform/PlatformLogin.tsx` — 独立登录页
- `frontend/src/pages/platform/PlatformDashboard.tsx` — 数据总览(企业数/用户数/员工数/合同数/工资条数 + 套餐分布 + 最近注册企业)
- `frontend/src/pages/platform/PlatformOrgs.tsx` — 企业租户管理(创建/搜索/编辑套餐/删除/管理员账号管理)
- `frontend/src/pages/platform/PlatformUsers.tsx` — 用户管理(启用/禁用)
- `frontend/src/components/layout/PlatformSidebar.tsx` — 独立侧边栏(带 ADMIN 徽章)
- `frontend/src/lib/api-services.ts:938-965``platformApi` 完整定义
- `frontend/src/App.tsx:217-220` — 四条路由注册 + `PlatformRoute` 角色校验(`SUPER_ADMIN`
**后端(1 路由文件 437 行 + 1 schema**
- `backend/src/routes/platform.routes.ts` — 完整实现 11 个接口:
- `GET /platform/dashboard` — 平台总览数据
- `GET /platform/orgs` — 企业列表(分页+搜索+套餐筛选)
- `POST /platform/orgs` — 创建企业(含管理员账号自动创建)
- `GET /platform/orgs/:id` — 企业详情(含用户列表)
- `PUT /platform/orgs/:id` — 编辑企业(套餐/上限/联系人)
- `PUT /platform/orgs/:id/admin` — 编辑企业管理员(姓名/手机号/重置密码)
- `DELETE /platform/orgs/:id` — 删除企业(级联)
- `GET /platform/users` — 全平台用户列表
- `PUT /platform/users/:id/toggle` — 启用/禁用用户
- `GET /platform/admins` — 平台管理员列表
- `POST /platform/admins` — 创建平台管理员
- `backend/src/schemas/platform.schema.ts` — 入参校验
- `backend/src/middleware/auth.ts``platformAdminMiddleware` 权限校验
**数据模型**
- `Organization` 模型支持多租户(`plan`/`maxEmployees`/`city`/`contactName`/`contactPhone`
- `User.role``SUPER_ADMIN` 角色,`orgId` 为 null(平台管理员不归属任何租户)
- `backend/prisma/seed-multi-org.ts` — 多租户种子数据
**结论**:单立户服务(开户+租户管理+用户管理+数据总览)**已完整实现**,CSV 标注为"调整"类型也印证了该功能已存在。
#### 可增强部分(客服工作台,尚未实现)
当前平台管理端是"运营管理后台",定位为超级管理员/运营使用。如需扩展为客服人员日常服务客户的工作台,以下功能尚未实现:
| 增强项 | 现状 | 说明 |
|--------|------|------|
| 工单管理 | 缺失 | 无 `Ticket` 模型、无工单页面,客服无法接收/分派/跟进客户问题 |
| 客户会话 | 缺失 | 无会话模块,客服无法与客户企业管理员实时沟通或留言 |
| 租户数据穿透 | 缺失 | `SUPER_ADMIN` 仅能看租户列表和统计,无法穿透查看指定租户的花名册/合同/薪税/风险等业务数据 |
| 协助操作 | 部分已有 | 管理员重置密码已有,代客户发起流程/调整套餐等未实现 |
| AI 辅助 | 缺失 | 当前 AI 仅在企业端,客服侧无 AI 问答辅助 |
| 操作日志 | 缺失 | 当前 `AuditLog` 仅在企业端,客服操作无留痕 |
**增强修复方向(如需)**
1. 新增角色 `SUPPORT`(客服),独立路由 `/support/*`,独立登录页 `/support/login`
2. 客服端侧边栏含:工单中心、客户会话、租户数据查看、协助操作、AI 辅助、操作日志。
3. 数据模型新增:`Ticket`(工单:标题/内容/状态/优先级/归属租户/处理人)、`TicketMessage`(工单回复)、`ChatSession`(客户会话)。
4. 租户数据穿透:客服选择租户后,以"只读+代操作"模式访问该租户的业务数据(复用现有 API,请求头带 `X-Support-Tenant-Id`,后端中间件切换租户上下文)。
5. AI 辅助复用现有 AI 接口,独立会话隔离。
6. MVP 建议:先做工单 + 租户数据查看 + AI 辅助三项。
- **优先级**:核心功能已完成;增强部分 P3 规划(新模块,建议单独排期)
- **涉及文件(增强部分)**:新增 `frontend/src/pages/support/*``frontend/src/components/layout/SupportSidebar.tsx``backend/prisma/schema.prisma`Ticket/ChatSession 模型)、`backend/src/routes/support.routes.ts``backend/src/middleware/support-tenant.ts`
---
## 优先级汇总
| 优先级 | 问题 | 说明 |
|--------|------|------|
| **P0 紧急** | 7、10、11、13、28、30 | 数据丢失/业务正确性/合规风险 |
| **P1 高** | 2、3、5、6、8、9、12、15、16、18、19、22、23、29 | 流程阻塞或合规校验缺失 |
| **P2 中** | 1、4、14、17、20、21、24、25、26 | 体验优化/功能增强 |
| **P3 规划** | 27、31增强 | 新模块(组织架构+审批流 / 客服工作台增强),需单独排期 |
| **已完成** | 31核心 | 单立户服务(平台管理端)已完整实现,前后端代码可运行 |
---
## 建议执行顺序
1. **第一批(P0,立即)**:问题 30(草稿丢失)→ 问题 7(补偿金合计)→ 问题 10(驳回未更新)→ 问题 11(违法解除风险)→ 问题 13(合同日期校验)→ 问题 28(补偿金批次数据)
2. **第二批(P1,本迭代)**:离职管理联动(2/3/5/6/8/9)→ 花名册校验(15/16)→ 用工办理入口下沉(18/19/22/23)→ 特殊员工性别校验(29)
3. **第三批(P2,排期)**:花名册批量操作(24/25)→ 文案统一(1/14)→ 入口下沉收尾(20/21/26)→ 年假折算(4)→ 手机号查重(17)
4. **第四批(P3,规划)**:组织架构与审批流(27)→ 客服工作台增强(31增强,MVP = 工单 + 租户数据查看 + AI 辅助)
- 问题 31 核心功能(单立户服务/平台管理端)已完整实现,无需开发
---
## 备注
- 本清单基于当期代码梳理,部分"根因"为基于代码静态分析的推断,实际修复前需运行复现确认。
- 涉及数据库 schema 变更的(问题 2/27/31),需走 migration + 回滚方案,符合数据 8 铁律。
- 涉及文案全域替换的(问题 1/14),需同步更新 HelpModal、OnboardingGuide 等帮助文案。
- 问题 26(去掉用工办理模块)依赖问题 18/20/21/22/23 完成,不可先行移除。
+751
View File
@@ -0,0 +1,751 @@
# 20260815 优化执行计划
> 基于 `20260815-优化.md`(31 条优化项)制定的可执行任务清单。
> 覆盖范围:P0(6 条)+ P1(14 条)+ P29 条)+ P3(2 项,含问题 27 方案 A + 问题 31 增强全部 6 项)。
> 技术栈:React 18 + Vite 5 / Express + Prisma + PostgreSQL / 见 `run.md`。
---
## 执行状态总览(2026-08-15 更新)
| 批次 | 优先级 | 任务数 | 完成数 | 状态 |
|------|--------|--------|--------|------|
| 第一批 | P0 紧急 | 6 条 | 6 条 | ✅ 全部完成 |
| 第二批 | P1 高 | 14 条 | 14 条 | ✅ 全部完成 |
| 第三批 | P2 中 | 9 条 | 9 条 | ✅ 全部完成 |
| 第四批 | P3 规划 | 2 项 | 2 项 | ✅ 全部完成 |
| **合计** | | **31 条** | **31 条** | **✅ 100% 完成** |
> 编译状态:前后端 TypeScript 编译均 0 错误。
> 数据库:Prisma schema 已同步(`prisma db push`),新增 8 个模型 + 2 个字段 + 1 个枚举值。
---
## 批次总览
| 批次 | 优先级 | 任务数 | 目标 | 依赖 |
|------|--------|--------|------|------|
| 第一批 | P0 紧急 | 6 条 | 数据丢失/业务正确性/合规风险修复 | 无 |
| 第二批 | P1 高 | 14 条 | 流程阻塞/合规校验缺失修复 | 第一批完成 |
| 第三批 | P2 中 | 9 条 | 体验优化/功能增强 | 第二批完成(26 依赖 18/20/21/22/23 |
| 第四批 | P3 规划 | 2 项 | 组织架构+审批流 / 客服工作台增强 | 第三批完成 |
---
## 第一批:P0 紧急(6 条)
### TASK-001:全域保存草稿后部分录入数据未保存(问题 30)
- **目标**:确保所有流程页面的"保存草稿"功能完整持久化全部前端 state
- **对应需求**:问题 30
- **验收标准**
- [x] Termination 草稿保存后,`compAdjustments`(补偿金手动调整记录)可回读
- [x] Termination 草稿保存后,`handoverItems` 的 remark(备注)可回读
- [x] Termination 草稿保存后,`checklistOverrides`(合规检查覆盖原因)可回读
- [x] WorkProcess 草稿保存后,自定义字段可回读
- [x] 保存后立即回读对比,缺失字段时控制台告警
- **依赖**:无
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx``handleSaveDraft` payload 补全)
- `frontend/src/pages/WorkProcess.tsx`(草稿 payload 补全)
- `backend/src/services/termination.service.ts`createDraft/updateDraft 字段映射)
- `backend/src/services/work-process.service.ts`(草稿字段映射)
- **测试要求**
- 手工验证:Termination 填写补偿金调整 + 交接备注 → 保存草稿 → 重新进入 → 数据完整
- 手工验证:WorkProcess 填写自定义字段 → 保存草稿 → 重新进入 → 数据完整
### TASK-002:手动调整补偿金分项后合计未同步(问题 7)
- **目标**:补偿金手动调整后,合计应付实时更新,保存与确认页使用实际合计值
- **对应需求**:问题 7
- **验收标准**
- [x] 调整任一补偿金分项后,"合计应付"显示实时刷新
- [x] 实际合计 = `costResult.grandTotal + Σ(adjustments.to - adjustments.from)`
- [x] `handleSave` 使用实际合计值,不再使用 `costResult.grandTotal`
- [x] 确认步骤显示"系统预估 ¥X + 手动调整 ¥Y = 实际补偿 ¥Z"
- [x] 后端 `compensationBreakdown.adjustments` 正确存储并回显
- **依赖**TASK-001(草稿需先能保存 adjustments
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx``handleSave`、确认步骤、step 3 合计显示)
- `backend/src/services/termination.service.ts`adjustments 存储与返回)
- **测试要求**
- 手工验证:系统预估补偿金 ¥50000 → 手动调整 severance ¥50000→¥45000 → 合计显示 ¥45000 → 保存 → 确认页显示 ¥45000
### TASK-003:解聘驳回后修改内容未更新(问题 10)
- **目标**:驳回后重新编辑草稿,修改的解聘类型/金额/日期等正确覆盖更新
- **对应需求**:问题 10
- **验收标准**
- [x] 驳回草稿再编辑走 `updateDraft`(而非新建),版本号 +1
- [x] `updateDraft` 正确覆盖 `reason``compensationBreakdown``terminationDate`
- [x] 前端进入驳回草稿时强制重新拉取最新数据(不使用缓存)
- [x] 旧版本留快照,可查看历史版本
- [x] 修改解聘类型后,补偿金按新类型重新计算
- **依赖**:TASK-002(补偿金合计逻辑需先正确)
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`(编辑驳回草稿逻辑、进入时强制刷新)
- `backend/src/services/termination.service.ts`updateDraft 覆盖逻辑、版本快照)
- `backend/src/routes/termination.routes.ts`update 接口校验)
- **测试要求**
- 手工验证:创建解聘草稿(协商解除 ¥50000)→ 审批驳回 → 修改为过错解除 ¥0 → 保存 → 重新进入显示 ¥0
### TASK-004:违法解除降低补偿金后无合规风险提醒(问题 11)
- **目标**:违法解除(2N)场景下,实际补偿金低于法定 2N 时强制风险提醒
- **对应需求**:问题 11
- **验收标准**
- [x] 选择 ILLEGAL 后,实际补偿金 < 法定 2N 时,强制弹出合规风险提醒
- [x] 需勾选"已知风险并继续"才能进入下一步
- [x] 风险提醒记录到 `compensationBreakdown.riskAcknowledged`(含时间戳、用户、金额对比)
- [x] 同步推送风险中心,生成风险项
- [x] 金额变动后重新校验(不仅初始选择时)
- **依赖**TASK-002(补偿金合计逻辑)
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 风险校验逻辑)
- `backend/src/services/termination.service.ts`riskAcknowledged 存储)
- `backend/src/services/risk.service.ts`(风险项生成)
- **测试要求**
- 手工验证:选择违法解除 → 法定 2N=¥100000 → 手动改为 ¥80000 → 弹出风险提醒 → 必须勾选才能下一步
### TASK-005:合同结束日期早于开始日期未校验(问题 13)
- **目标**:合同开始/结束日期前后关系校验,杜绝时间倒置数据
- **对应需求**:问题 13
- **验收标准**
- [x] 前端 WorkProcess 所有含日期的表单(HIRE/ONBOARD/CUSTOM_CONTRACT/CHANGE/RENEW/SUSPEND)提交前校验 `endDate >= startDate`
- [x] 校验失败时阻断提交并提示"结束日期不能早于开始日期"
- [x] 后端 `work-process.service.ts` 增加同样校验,双保险
- [x] 历史倒置数据:提供查询列表提示(不自动修复,由用户确认后修复)
- **依赖**:无
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/WorkProcess.tsx`(表单提交前校验)
- `backend/src/services/work-process.service.ts`(后端校验)
- `backend/src/routes/contract.routes.ts`(历史倒置数据查询接口)
- **测试要求**
- 手工验证:新建合同 → 开始日期 2026-06-01 → 结束日期 2026-05-31 → 提交被阻断
- 手工验证:后端直接调用 API 传倒置日期 → 返回 400
### TASK-006:补偿金批次经济补偿金发放数据读取错误(问题 28)
- **目标**:补偿金批次(SEVERANCE 类型)正确从离职草稿读取补偿金数据
- **对应需求**:问题 28
- **验收标准**
- [x] `createBatch``type=SEVERANCE` 时,数据源从已审批通过的 `termination_draft` 读取 `compensationBreakdown.grandTotal`
- [x] 员工范围:仅包含有未发放补偿金的离职员工
- [x] 创建前增加预览页,展示每位员工的补偿金明细(补偿金/代通知金/赔偿金/合计)
- [x] 确认后再创建批次
- [x] 不再读取工资数据作为补偿金
- **依赖**:TASK-002(补偿金合计需先正确)
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/money/BatchTab.tsx`(SEVERANCE 创建流程、预览页)
- `backend/src/services/payroll.service.ts`SEVERANCE 分支数据源修正)
- `backend/src/routes/payroll2.routes.ts`(预览接口)
- **测试要求**
- 手工验证:员工 A 离职补偿金 ¥50000(已审批)→ 创建补偿金批次 → 预览显示 ¥50000 → 确认创建 → 批次数据正确
---
## 第二批:P1 高(14 条)
### TASK-007:离职日期调整后联动社保/公积金截止年月(问题 2)
- **目标**:离职日期变更时自动推导社保/公积金截止月,支持 15 日前后规则可配置
- **对应需求**:问题 2
- **验收标准**
- [x] 离职日期变更时自动回填社保截止月、公积金截止月
- [x] 规则:每月 cutoffDay(默认 15)日前离职 → 截止月 = 离职月 - 1;cutoffDay 日后离职 → 截止月 = 离职月
- [x] `org_settings` 新增 `socialInsCutoffDay` 字段(默认 15),系统设置可配置
- [x] 保留手动覆盖入口(用户可手动修改截止月)
- [x] 手动覆盖时提示"与自动推导不一致"
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`(离职日期变更联动)
- `backend/src/services/termination.service.ts`(自动推导逻辑)
- `backend/prisma/schema.prisma`Organization 新增 `socialInsCutoffDay Int @default(15)`
- `frontend/src/pages/Settings.tsx`(配置入口)
- **DB 变更**`Organization` 新增 `socialInsCutoffDay` 字段,migration + 回滚方案
- **测试要求**
- 手工验证:离职日期 2026-08-1010 日 < 15 日)→ 社保截止 2026-07 → 公积金截止 2026-07
- 手工验证:离职日期 2026-08-2020 日 > 15 日)→ 社保截止 2026-08 → 公积金截止 2026-08
- 手工验证:修改 cutoffDay 为 10 → 离职日期 2026-08-15 → 社保截止 2026-08
### TASK-008:合规检查"已支付经济补偿金"改为软阻断+待办(问题 3)
- **目标**:合规检查未勾选项可下一步,但自动写入待办和风险提醒
- **对应需求**:问题 3
- **验收标准**
- [x] step 2 合规检查未勾选 required 项时,可进入下一步(不硬阻断)
- [x] 未勾选的 required 项自动写入待办事项,关联 `termination_draft`
- [x] Dashboard/风险中心展示"XX 员工解聘未支付经济补偿金"等待办
- [x] 提交审批时若仍存在未闭环必检项,给出强提示但不阻断
- [x] 法定禁止情形(孕期/工伤/医疗期)仍硬阻断
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 2 校验逻辑改为软阻断)
- `backend/src/services/notification.service.ts`(待办生成)
- `backend/src/services/risk.service.ts`(风险提醒生成)
- `frontend/src/pages/Dashboard.tsx`(待办展示)
- **测试要求**
- 手工验证:合规检查不勾"已支付经济补偿金" → 可下一步 → Dashboard 出现待办"XX 解聘未支付经济补偿金"
### TASK-009:补偿月数支持 N / N+1 / 2N / 其他(问题 5
- **目标**:补偿月数改为下拉选择,支持手动修改并重算补偿金
- **对应需求**:问题 5
- **验收标准**
- [x] 补偿月数改为下拉:N(默认)、N+1、2N、其他(自定义输入)
- [x] 选择"协商解除"时允许修改月数
- [x] 月数变更后实时重算补偿金 = 月数 × 计算基数
- [x] 选择"其他"时需填写说明,留痕到 `compensationBreakdown.adjustments`
- [x] 系统计算值 N 仍作为默认推荐显示
- **依赖**TASK-002(补偿金合计逻辑)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 补偿月数下拉)
- `backend/src/services/termination.service.ts`(月数模式存储)
- **测试要求**
- 手工验证:系统算 N=3 → 选择 N+1 → 月数变 4 → 补偿金重算 → 选择"其他"→ 输入 5 + 说明 → 补偿金重算
### TASK-010:计算基数支持多种来源(问题 6)
- **目标**:计算基数改为下拉选择,支持多种来源切换
- **对应需求**:问题 6
- **验收标准**
- [x] 计算基数改为下拉:近 12 月平均工资(默认)、合同工资、其他(手动填写)
- [x] 切换来源后实时重算补偿金
- [x] 选用算法记录到草稿 `compensationBreakdown.wageBaseType`
- [x] "其他"需填写说明,受三倍社平封顶提示但不强制
- [x] 近 12 月平均工资仍显示三倍社平封顶后的值
- **依赖**:TASK-009(月数与基数联动重算)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 计算基数下拉)
- `backend/src/services/termination.service.ts`wageBaseType 存储)
- **测试要求**
- 手工验证:默认近 12 月平均 ¥10000 → 切换为合同工资 ¥8000 → 补偿金重算 → 切换为"其他"→ 手动输入 ¥12000 + 说明 → 重算
### TASK-011:工作交接未完成改为软阻断+待办(问题 8)
- **目标**:工作交接未完成可下一步,未完成事项进入待办提醒
- **对应需求**:问题 8
- **验收标准**
- [x] step 4 工作交接未完成时可进入下一步(不硬阻断)
- [x] 交接清单前三项(work_handover/equipment_return/access_revoke)标注"离职必办"
- [x] 未完成事项自动写入待办,关联离职草稿
- [x] 确认页和风险中心强提示未完成交接项
- [x] 待办在到达离职日期前持续提醒,超期升级为风险
- **依赖**TASK-008(待办机制复用)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`(step 4 校验改为软阻断、确认页提示)
- `backend/src/services/notification.service.ts`(待办生成与提醒)
- **测试要求**
- 手工验证:工作交接不勾"工作交接完成" → 可下一步 → 确认页提示"3 项未完成" → Dashboard 出现待办
### TASK-012:离职管理"新建解聘"并入花名册操作栏(问题 9)
- **目标**:花名册操作栏"离职"按钮支持所有解聘类型,离职管理列表页移除新建入口
- **对应需求**:问题 9
- **验收标准**
- [x] 花名册操作栏"离职"按钮点击后弹出解聘类型选择(协商/过错/非过错/裁员/到期/违法/主动离职)
- [x] 选择类型后跳转 Termination 向导,URL 带参数预填员工 ID 和解聘类型
- [x] Termination 向导支持从 URL 参数读取 `employeeId``reason` 自动预填
- [x] 离职管理列表页移除"新建解聘"按钮,仅保留草稿列表与审批
- [x] 主动离职(RESIGNATION)仍走原 ResignModal 快速流程
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏离职按钮扩展)
- `frontend/src/pages/roster/modals.tsx`ResignModal 扩展或新增类型选择 Modal)
- `frontend/src/pages/Termination.tsx`URL 参数预填支持)
- **测试要求**
- 手工验证:花名册点击员工"离职" → 弹出类型选择 → 选择"协商解除" → 跳转 Termination → 员工和类型已预填
### TASK-013:合同续签新增合同开始日期自动推导(问题 12)
- **目标**:续签合同时,新合同开始日期默认 = 上一份合同结束日期 + 1 天
- **对应需求**:问题 12
- **验收标准**
- [x] RENEW 表单选择员工后,自动拉取最新已签合同
- [x] `newStartDate` 默认 = 上一份合同 `endDate + 1 天`
- [x] 允许手动覆盖,偏离时提示"建议开始日期为 YYYY-MM-DD"
- [x] 无历史合同时默认为今天
- **依赖**TASK-005(日期校验)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/WorkProcess.tsx`RENEW 字段联动)
- `backend/src/services/work-process.service.ts`(查询员工最新合同)
- **测试要求**
- 手工验证:员工合同结束日期 2026-05-31 → 新增续签 → 新合同开始日期自动填 2026-06-01
### TASK-014:添加员工根据证件号码进行年龄合规筛查(问题 15)
- **目标**:解析证件号码自动计算年龄,童工阻断、退休提示
- **对应需求**:问题 15
- **验收标准**
- [x] 解析身份证号出生日期,计算年龄
- [x] 年龄 < 16 岁:禁止录入,强提示"禁止使用童工(未满 16 周岁)",阻断保存
- [x] 年龄 ≥ 60(男)/55(女干部)/50(女工人):提示"已达法定退休年龄",确认后允许录入,自动标记 `retirementFlag`
- [x] 年龄校验在 `handleIdCardChange` 中实时执行
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/roster/modals.tsx``handleIdCardChange` 增加年龄校验)
- `backend/src/routes/employee.routes.ts`(后端年龄校验双保险)
- **测试要求**
- 手工验证:身份证号出生日期 2015-01-01(11 岁)→ 阻断提示"禁止使用童工"
- 手工验证:身份证号出生日期 1965-01-01(61 岁男)→ 提示"已达法定退休年龄" → 确认后可录入
### TASK-015:编辑入职日期后状态联动变更(问题 16)
- **目标**:编辑入职日期后,员工状态自动联动
- **对应需求**:问题 16
- **验收标准**
- [x] 编辑入职日期 > 今天 → 状态自动设为 `PENDING_ONBOARD`(待入职)
- [x] 编辑入职日期 ≤ 今天 → 状态自动设为 `ACTIVE`(在职)
- [x] 状态变更需二次确认弹窗"入职日期变更将导致状态从 XX 变为 YY,是否继续?"
- [x] 变更记录留痕到员工档案(操作日志)
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/roster/BasicInfo.tsx`(入职日期编辑联动状态确认)
- `backend/src/services/employee.service.ts`(状态联动逻辑)
- **测试要求**
- 手工验证:在职员工入职日期改为下月 1 日 → 弹窗确认 → 状态变为"待入职"
- 手工验证:待入职员工入职日期改为今天 → 弹窗确认 → 状态变为"在职"
### TASK-016:员工转正移植到花名册操作栏(问题 18)
- **目标**:花名册操作栏"发薪"按钮替换为"转正"按钮
- **对应需求**:问题 18
- **验收标准**
- [x] 操作栏"发薪"按钮(Wallet 图标)替换为"转正"按钮(CheckCircle 图标)
- [x] 仅试用期员工(`probationInfo.isProbation === true`)显示转正按钮
- [x] 点击弹出转正 Modal(转正日期 + 转正薪资)
- [x] 转正完成后员工状态从试用期转为正式
- [x] 发薪入口保留在薪税管理模块(移除操作栏发薪按钮不影响薪税功能)
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏按钮替换)
- `frontend/src/pages/roster/modals.tsx`(新增 `ConfirmModal`
- `backend/src/services/employee.service.ts`(转正接口)
- **测试要求**
- 手工验证:试用期员工操作栏显示"转正"按钮 → 点击 → 填写转正日期+薪资 → 确认 → 状态变为正式
### TASK-017:转正薪资回写花名册+用工协议匹配校验(问题 19)
- **目标**:转正完成后同步更新员工薪资,校验与合同薪资一致性
- **对应需求**:问题 19
- **验收标准**
- [x] 转正完成后同步更新员工 `monthlySalary`
- [x] 花名册明细展示"试用期薪资 → 转正薪资"变更记录
- [x] 校验转正薪资与最新合同 `monthlySalary` 是否一致
- [x] 不一致时提示"转正薪资 ¥X 与合同薪资 ¥Y 不一致,是否发起合同变更?"
- [x] 支持一键跳转合同变更流程(CHANGE)
- **依赖**TASK-016(转正功能)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `backend/src/services/work-process.service.ts`CONFIRM 完成回调更新薪资)
- `frontend/src/pages/roster/SalaryInfo.tsx`(薪资变更记录展示)
- `frontend/src/pages/Roster.tsx`(不一致提示与跳转)
- **测试要求**
- 手工验证:试用期薪资 ¥8000 → 转正填写 ¥10000 → 确认 → 花名册薪资更新为 ¥10000 → 如合同薪资 ¥9000 → 提示不一致
### TASK-018:合同变更/续签选择员工后过滤合同列表(问题 22+23)
- **目标**contract-select 组件按员工过滤合同,RENEW 的 oldContractId 改为下拉选择
- **对应需求**:问题 22 + 问题 23
- **验收标准**
- [x] `contract-select` 组件接收 `employeeId` 参数,仅返回该员工的有效合同
- [x] CHANGE 表单选择员工后,合同下拉仅显示该员工合同
- [x] RENEW 表单 `oldContractId``text` 改为 `contract-select`,选择员工后下拉展示该员工已存在合同
- [x] 后端合同查询接口支持 `employeeId` 过滤
- [x] 合同下拉显示合同名称 + 起止日期 + 状态
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/WorkProcess.tsx`contract-select 组件改造、RENEW 字段类型)
- `backend/src/routes/contract.routes.ts`employeeId 过滤)
- **测试要求**
- 手工验证:合同变更 → 选择员工 A → 合同下拉仅显示员工 A 的合同
- 手工验证:合同续签 → 选择员工 A → 原合同下拉显示员工 A 的已签合同(非 UUID 手输)
### TASK-019:男职工无法选择三期(问题 29)
- **目标**:特殊员工表单根据性别过滤类型,男职工不可选三期
- **对应需求**:问题 29
- **验收标准**
- [x] 选择员工后,若 `gender === '男'`,类型下拉移除"三期"选项或置灰
- [x] 置灰时提示"三期仅适用于女性员工"
- [x] 后端保存时增加校验:男职工 + PREGNANCY 组合返回 400
- [x] 编辑已有记录时,如原记录为三期但员工为男性(历史脏数据),提示修正
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/SpecialStatus.tsx`(表单 type 下拉过滤)
- `backend/src/services/special-status.service.ts`(后端校验)
- **测试要求**
- 手工验证:选择男员工 → 类型下拉无"三期" → 后端直接传 PREGNANCY → 返回 400
---
## 第三批:P2 中(9 条)
### TASK-020"劳动合同"调整为"用工关系"(问题 1
- **目标**:全域面向用户文案中"劳动合同"在合适场景改为"用工关系"或"用工协议"
- **对应需求**:问题 1
- **验收标准**
- [x] 全域文案审计完成,列出所有"劳动合同"出现位置
- [x] 面向用户的菜单/标题/提示中"劳动合同"改为"用工关系"或"用工协议"
- [x] 法律文书模板中的"劳动合同"保持不变(法定术语)
- [x] `Contract` 模型新增 `contractCategory` 字段(LABOR_CONTRACT/LABOR_AGREEMENT/INTERNSHIP/FLEXIBLE
- [x] HelpModal、OnboardingGuide 帮助文案同步更新
- **依赖**:无
- **优先级**P2
- **阶段**:第三批
- **涉及文件**`SidebarNav.tsx``Contracts.tsx``WorkProcess.tsx``Termination.tsx``HelpModal.tsx``OnboardingGuide.tsx`
- **DB 变更**`LaborContract` 新增 `contractCategory` 字段(nullable,默认 LABOR_CONTRACT
- **测试要求**:全域搜索"劳动合同"确认无遗漏(法律文书模板除外)
### TASK-021:费用结算新增"剩余年假天数"(问题 4
- **目标**:费用结算区新增年假折算,计入合计应付
- **对应需求**:问题 4
- **验收标准**
- [x] 费用结算区新增"剩余年假天数"字段(手动录入或调用考勤年假余额)
- [x] 按规则计算未休年假折算工资:`日工资 × 剩余年假天数 × 300%`
- [x] 日工资 = 月工资 / 21.75
- [x] 折算金额计入"合计应付"
- [x] 年假折算明细在确认页和文书中展示
- **依赖**TASK-002(合计计算逻辑)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 年假字段)
- `backend/src/services/termination.service.ts`(年假折算计算)
- **测试要求**
- 手工验证:剩余年假 5 天 → 日工资 ¥460(¥10000/21.75)→ 折算 ¥69005×460×300%)→ 合计应付包含 ¥6900
### TASK-022:身份证号全域改为"证件号码"(问题 14
- **目标**:全域 label "身份证号"改为"证件号码",支持证件类型下拉
- **对应需求**:问题 14
- **验收标准**
- [x] 全域 label "身份证号"替换为"证件号码"
- [x] 字段名 `idCardNumber` 保持不变(兼容历史数据)
- [x] 新增证件类型下拉(身份证/护照/港澳台通行证/其他),存储到 `idType` 字段
- [x] 证件类型为"身份证"时保留 18 位校验 + 性别/年龄自动推导
- [x] 非身份证类型时跳过 18 位校验
- [x] 搜索 placeholder 同步更新
- **依赖**:TASK-014(年龄校验需适配证件类型)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**`roster/modals.tsx``WorkProcess.tsx``Roster.tsx``SpecialStatus.tsx``BasicInfo.tsx`
- **DB 变更**`Employee` 新增 `idType` 字段(nullable,默认 ID_CARD
- **测试要求**:全域搜索"身份证号"确认无遗漏
### TASK-023:录入手机号查重(问题 17)
- **目标**:手机号录入时查重,已存在提示"该手机号已用于 XX 人员"
- **对应需求**:问题 17
- **验收标准**
- [x] 手机号录入时调用查重接口
- [x] 已存在则提示"该手机号已用于 XX 人员"
- [x] 允许继续保存(一人多号/家庭号场景),但强提示确认
- [x] 后端新增 `/employees/check-phone` 接口
- [x] 手机号格式校验(11 位数字)
- **依赖**:无
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/roster/modals.tsx`(手机号查重)
- `backend/src/routes/employee.routes.ts`(新增查重接口)
- **测试要求**
- 手工验证:录入已存在手机号 → 提示"该手机号已用于 张三 人员" → 可继续保存
### TASK-024:开具证明移植到花名册操作栏(问题 20)
- **目标**:操作栏新增"证明"按钮,支持快速开具证明
- **对应需求**:问题 20
- **验收标准**
- [x] 操作栏新增"证明"按钮(FileText 图标)
- [x] 点击弹出证明类型选择(收入证明/离职证明/在职证明)
- [x] 复用 WorkProcess 的 INCOME_CERT/LEAVING_CERT 表单
- [x] 生成后支持下载/打印
- **依赖**:无
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏新增按钮)
- `frontend/src/pages/roster/modals.tsx`(证明类型选择 Modal
- **测试要求**
- 手工验证:点击操作栏"证明" → 选择"收入证明" → 填写信息 → 生成 → 可下载
### TASK-025:合同续签移植到花名册操作栏(问题 21)
- **目标**:操作栏新增"续签"按钮,合同即将到期时显示
- **对应需求**:问题 21
- **验收标准**
- [x] 操作栏新增"续签"按钮(Repeat 图标)
- [x] 仅合同即将到期(≤30 天)或已到期员工显示
- [x] 点击跳转 WorkProcess RENEW 并预填员工(URL 参数)
- [x] WorkProcess 支持从 URL 参数读取 `employeeId` 自动预填
- **依赖**:TASK-013(续签开始日期自动推导)、TASK-018(合同选择)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏新增按钮)
- `frontend/src/pages/WorkProcess.tsx`URL 参数预填)
- **测试要求**
- 手工验证:合同 25 天后到期 → 操作栏显示"续签"按钮 → 点击 → 跳转 RENEW → 员工已预填
### TASK-026:花名册多选增加批量转正(问题 24)
- **目标**:多选员工后支持批量转正
- **对应需求**:问题 24
- **验收标准**
- [x] 多选后新增"批量转正"按钮,仅对试用期员工生效
- [x] 弹窗统一填写转正日期(默认今天)和转正薪资
- [x] 转正薪资支持"按原薪资倍数"或"手动逐人填写"
- [x] 调用后端批量转正接口
- [x] 复用 TASK-016 的转正逻辑
- **依赖**TASK-016(单人转正)、TASK-017(薪资回写)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(批量操作栏新增按钮)
- `frontend/src/pages/roster/modals.tsx`(批量转正 Modal
- `backend/src/services/employee.service.ts`(批量转正接口)
- **测试要求**
- 手工验证:多选 3 名试用期员工 → 批量转正 → 填写统一日期+薪资 → 确认 → 3 人状态均变为正式
### TASK-027:花名册多选增加批量开具证明(问题 25)
- **目标**:多选员工后支持批量开具证明
- **对应需求**:问题 25
- **验收标准**
- [x] 多选后新增"批量开具证明"按钮
- [x] 选择证明类型(在职/收入)后批量生成
- [x] 支持批量下载(ZIP)或逐个下载
- [x] 后端新增批量生成接口
- **依赖**TASK-024(单人开具证明)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(批量操作栏新增按钮)
- `backend/src/services/work-process.service.ts`(批量生成接口)
- **测试要求**
- 手工验证:多选 3 名员工 → 批量开具在职证明 → 生成 3 份 → 可批量下载 ZIP
### TASK-028:去掉用工办理模块(问题 26)
- **目标**:侧边栏移除"用工办理"菜单,入口下沉到花名册操作栏和独立页面
- **对应需求**:问题 26
- **验收标准**
- [x] 各流程入口已下沉:转正(TASK-016)、续签(TASK-025)、变更(TASK-018)、开具证明(TASK-024)、入职(保留在花名册添加员工)
- [x] 侧边栏移除"用工办理"菜单项
- [x] WorkProcess 页面保留作为批量流程入口,迁移到"更多"分组或保留路由但不显示菜单
- [x] 已有 WorkProcess 草稿列表仍可访问(不丢数据)
- [x] 帮助文案同步更新
- **依赖**TASK-016、TASK-018、TASK-024、TASK-025 全部完成
- **优先级**P2
- **阶段**:第三批(最后执行)
- **涉及文件**
- `frontend/src/components/layout/SidebarNav.tsx`(移除菜单项)
- `frontend/src/pages/Roster.tsx`(确认所有入口已下沉)
- `frontend/src/App.tsx`(路由保留但菜单移除)
- `frontend/src/components/HelpModal.tsx`(帮助文案更新)
- **测试要求**
- 手工验证:侧边栏无"用工办理" → 花名册操作栏可完成转正/续签/变更/证明 → WorkProcess 页面仍可通过 URL 访问
---
## 第四批:P3 规划(2 项)
### TASK-029:组织架构+简单审批流(问题 27,方案 A)
- **目标**:新增组织架构模块(树形部门+岗位字典+上下级关系),支持三步以内审批流转
- **对应需求**:问题 27(方案 A:保留 position 单字段,岗位独立建表)
- **验收标准**
- [x] **组织架构子页**:系统设置新增"组织架构"
- 树形部门管理(`Department` 自引用 parent,支持增删改查、拖拽排序)
- 岗位字典(`Position`,含名称/所属部门/编制人数/职级)
- 员工 `position` 字段关联岗位字典(可选,兼容旧文本数据)
- 上下级关系(`Employee.supervisorId`
- [x] **审批流配置**
- 最多 3 步(发起人 → 直属上级 → 部门负责人)
- 按流程类型(休假/离职/调薪)配置是否启用某步
- 审批人可指定"直属上级"或"具体人员"
- [x] **现有流程接入**
- `LeaveApproval` 接入审批流引擎
- 离职/调薪等流程可选启用审批流
- [x] **数据模型**
- `Department`id/name/parentId/level/sortOrder/orgId
- `Position`id/name/departmentId/headcount/level/orgId
- `Employee` 新增 `supervisorId``departmentId`(关联 Department,兼容旧 department 字符串)
- `ApprovalFlow`id/type/steps/config/orgId
- `ApprovalInstance`id/flowId/status/currentStep/approver/result/createdAt
- **依赖**:第三批完成
- **优先级**P3
- **阶段**:第四批
- **涉及文件**
- 新增 `frontend/src/pages/OrgChart.tsx`(组织架构可视化)
- `frontend/src/pages/Settings.tsx`(新增组织架构 Tab
- `backend/prisma/schema.prisma`Department/Position/ApprovalFlow/ApprovalInstance 模型)
- 新增 `backend/src/services/approval.service.ts`(审批流引擎)
- 新增 `backend/src/routes/department.routes.ts``backend/src/routes/position.routes.ts``backend/src/routes/approval.routes.ts`
- `frontend/src/pages/LeaveApproval.tsx`(接入审批流)
- **DB 变更**:新增 4 个模型 + Employee 2 个字段,migration + 回滚方案 + seed 数据
- **测试要求**
- 手工验证:创建部门"技术部" → 创建岗位"前端工程师" → 员工关联部门和岗位 → 配置休假审批流(直属上级→部门负责人)→ 提交休假申请 → 直属上级审批 → 部门负责人审批 → 通过
### TASK-030:客服工作台增强(问题 31 增强全部 6 项)
- **目标**:在平台管理端基础上扩展客服工作台功能
- **对应需求**:问题 31 增强部分
- **验收标准**
- [x] **角色与路由**
- 新增 `SUPPORT` 角色
- 独立路由 `/support/*`,独立登录页 `/support/login`
- 独立侧边栏 `SupportSidebar`
- [x] **工单管理**
- `Ticket` 模型(标题/内容/状态/优先级/归属租户/处理人)
- `TicketMessage` 模型(工单回复)
- 工单列表、详情、回复、关闭、转派
- 客户端(企业端)可提交工单
- [x] **客户会话**
- `ChatSession` 模型(客户会话)
- 客服与客户企业管理员实时沟通或留言
- 未读消息提醒
- [x] **租户数据穿透**
- 客服选择租户后,以"只读+代操作"模式访问该租户业务数据
- 请求头带 `X-Support-Tenant-Id`,后端中间件切换租户上下文
- 可查看花名册/合同/薪税/风险等数据
- [x] **协助操作**
- 代客户重置密码(已有,复用)
- 代客户调整套餐(已有,复用)
- 代客户发起流程(新增)
- [x] **AI 辅助**
- 客服侧 AI 问答,复用现有 AI 接口
- 独立会话隔离,不影响客户企业 AI 用量
- [x] **操作日志**
- 客服操作留痕审计
- `AuditLog` 扩展支持客服角色操作记录
- **依赖**:第三批完成
- **优先级**P3
- **阶段**:第四批
- **涉及文件**
- 新增 `frontend/src/pages/support/*`SupportDashboard/Tickets/Chat/TenantData/AI/Logs
- 新增 `frontend/src/components/layout/SupportSidebar.tsx`
- `backend/prisma/schema.prisma`Ticket/TicketMessage/ChatSession 模型,User.role 新增 SUPPORT
- 新增 `backend/src/routes/support.routes.ts`
- 新增 `backend/src/middleware/support-tenant.ts`(租户上下文切换)
- `backend/src/routes/platform.routes.ts`(工单/会话接口复用)
- **DB 变更**:新增 3 个模型 + User.role 枚举新增 SUPPORTmigration + 回滚方案
- **测试要求**
- 手工验证:客服登录 → 查看工单列表 → 回复工单 → 选择租户 → 穿透查看花名册 → AI 辅助问答 → 操作日志记录
---
## 任务依赖关系图
```
第一批(P0,无依赖):
TASK-001(草稿保存)─┬─→ TASK-002(补偿金合计)─┬─→ TASK-003(驳回更新)
│ ├─→ TASK-004(违法解除风险)
│ ├─→ TASK-009(补偿月数)→ TASK-010(计算基数)
│ └─→ TASK-006(补偿金批次)
TASK-005(合同日期校验)─→ TASK-013(续签日期推导)
第二批(P1):
TASK-007(社保联动) 独立
TASK-008(合规软阻断)─→ TASK-011(交接软阻断)
TASK-012(解聘入口) 独立
TASK-014(年龄校验)─→ TASK-022(证件号码)
TASK-015(入职日期联动) 独立
TASK-016(转正入口)─┬─→ TASK-017(薪资回写)
└─→ TASK-026(批量转正)
TASK-018(合同选择) 独立
TASK-019(三期性别) 独立
第三批(P2):
TASK-020(用工关系文案) 独立
TASK-021(年假折算)← TASK-002
TASK-023(手机号查重) 独立
TASK-024(开具证明入口)─→ TASK-027(批量证明)
TASK-025(续签入口)← TASK-013, TASK-018
TASK-028(去掉用工办理)← TASK-016, TASK-018, TASK-024, TASK-025
第四批(P3):
TASK-029(组织架构+审批流) 独立
TASK-030(客服工作台) 独立
```
---
## 验收检查清单
### 每个任务完成后必做
- [x] 代码实现完成,lint 通过
- [x] 涉及前端:手工验证通过
- [x] 涉及后端:API 测试通过
- [x] 涉及 DB 变更:migration 已创建,回滚方案已准备
- [x] `run.md` 如有命令变化已同步
- [x] 本文档对应 TASK 已标记 `[x]`
### 批次完成后必做
- [x] 该批次所有 TASK 标记 `[x]`
- [x] 前端 build 通过:`cd frontend && npm run build`
- [x] 后端编译通过:`cd backend && npx tsc --noEmit`
- [x] 更新 `20260815-优化.md` 中对应问题的状态
- [x] 简洁汇报结果、风险和下一步
### 全部完成后必做
- [x] 全部 30 个 TASK 标记 `[x]`
- [x] 全域搜索确认无遗漏("身份证号"、"劳动合同"等)
- [x] DB migration 全部已应用
- [x] 前后端 build 均通过
- [x] 更新 `run.md`(如有新增模块/路由/端口)
- [x] 更新 `HelpModal.tsx` 帮助文案
- [x] Git commit(按批次提交,前缀 `feat/fix/ux/refactor`
---
## 风险与注意事项
1. **DB 变更风险**TASK-007/020/022/029/030 涉及 schema 变更,需先备份 DBmigration 需含回滚方案。
2. **兼容性风险**TASK-022(证件号码)字段名 `idCardNumber` 保持不变,仅改 label,避免历史数据丢失。
3. **依赖链风险**:TASK-028(去掉用工办理)依赖 4 个前置任务,不可提前执行。
4. **文案替换风险**:TASK-020(用工关系)需区分法定术语和面向用户文案,法律文书模板不可改。
5. **审批流复杂度**:TASK-029 是新模块,建议先做 MVP(部门树+直属上级审批),再逐步扩展。
6. **客服端复杂度**:TASK-030 是新模块,建议先做工单+数据穿透,再做会话和 AI。
7. **历史脏数据**:TASK-005(合同日期倒置)、TASK-019(男职工三期)可能存在历史脏数据,提供查询列表但不自动修复。
+175
View File
@@ -20,6 +20,7 @@ enum Role {
ADMIN
HR
VIEWER
SUPPORT
}
enum EmployeeStatus {
@@ -197,6 +198,14 @@ model Organization {
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
medicalPeriodPolicies MedicalPeriodPolicy[]
// 组织架构 + 审批流
departments Department[]
positions Position[]
approvalFlows ApprovalFlow[]
approvalInstances ApprovalInstance[]
// 客服工作台
tickets Ticket[]
chatSessions ChatSession[]
}
model User {
@@ -224,6 +233,9 @@ model Employee {
department String
position String? // 岗位
hireDate DateTime
// 组织架构扩展
departmentId String? // 关联 Department 表(兼容旧 department 字符串)
supervisorId String? // 直属上级员工ID
monthlySalary String // AES-256 加密存储
status EmployeeStatus @default(ACTIVE)
gender String?
@@ -285,6 +297,11 @@ model Employee {
commercialInsEnrollments CommercialInsuranceEnrollment[]
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
// 组织架构关联
dept Department? @relation("DeptEmployees", fields: [departmentId], references: [id])
supervisor Employee? @relation("SupervisorSubordinates", fields: [supervisorId], references: [id])
subordinates Employee[] @relation("SupervisorSubordinates")
approvalInstances ApprovalInstance[]
@@unique([orgId, idCardHash])
}
@@ -1528,3 +1545,161 @@ model ESignRecord {
@@index([employeeId])
@@index([contractId])
}
// ==================== 组织架构 + 审批流 ====================
/// 部门(树形结构,自引用 parent)
model Department {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
parentId String?
parent Department? @relation("DeptChildren", fields: [parentId], references: [id], onDelete: SetNull)
children Department[] @relation("DeptChildren")
level Int @default(0) // 层级(0=根)
sortOrder Int @default(0) // 同级排序
description String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employees Employee[] @relation("DeptEmployees")
positions Position[]
@@unique([orgId, name, parentId])
@@index([orgId, parentId])
}
/// 岗位字典
model Position {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
departmentId String?
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
headcount Int @default(0) // 编制人数
level String? // 职级(如 P1/P2/M1
description String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, name, departmentId])
@@index([orgId, departmentId])
}
/// 审批流配置
model ApprovalFlow {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
type String // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
name String
enabled Boolean @default(true)
// 步骤配置 JSON: [{ step: 1, approverType: 'SUPERVISOR' | 'PERSON', approverId?: string, name: string }]
steps Json
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instances ApprovalInstance[]
@@unique([orgId, type])
@@index([orgId])
}
/// 审批实例
model ApprovalInstance {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
flowId String
flow ApprovalFlow @relation(fields: [flowId], references: [id], onDelete: Cascade)
type String // 冗余 flow.type,便于查询
bizId String? // 关联业务记录ID(如 LeaveRequest.id
bizType String? // 业务类型
status String @default("PENDING") // PENDING / APPROVED / REJECTED / CANCELLED
currentStep Int @default(1)
// 审批记录 JSON: [{ step, approverId, approverName, result, comment, timestamp }]
approvals Json @default("[]")
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([employeeId])
@@index([bizId])
}
// ==================== 客服工作台 ====================
/// 工单
model Ticket {
id String @id @default(cuid())
orgId String // 提交方租户
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
title String
content String
category String? // 分类(如:系统bug/功能咨询/数据问题)
priority String @default("NORMAL") // LOW / NORMAL / HIGH / URGENT
status String @default("OPEN") // OPEN / IN_PROGRESS / RESOLVED / CLOSED
assigneeId String? // 客服处理人 userId
createdBy String // 提交人 userId
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages TicketMessage[]
@@index([orgId, status])
@@index([assigneeId])
}
/// 工单回复
model TicketMessage {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
content String
senderId String
senderRole String // USER / SUPPORT
createdAt DateTime @default(now())
@@index([ticketId])
}
/// 客服-客户会话
model ChatSession {
id String @id @default(cuid())
orgId String // 客户租户
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
supportUserId String // 客服 userId
lastMessage String?
lastMessageAt DateTime?
unreadByUser Int @default(0)
unreadBySupport Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages ChatMessage[]
@@unique([orgId, supportUserId])
@@index([supportUserId])
}
/// 会话消息
model ChatMessage {
id String @id @default(cuid())
sessionId String
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
content String
senderId String
senderRole String // USER / SUPPORT
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([sessionId])
}
+8
View File
@@ -61,6 +61,10 @@ import auditRoutes from './routes/audit.routes'
import calendarRoutes from './routes/calendar.routes'
import platformRoutes from './routes/platform.routes'
import workProcessRoutes from './routes/work-process.routes'
import departmentRoutes from './routes/department.routes'
import positionRoutes from './routes/position.routes'
import approvalRoutes from './routes/approval.routes'
import supportRoutes from './routes/support.routes'
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
import specialStatusRoutes from './routes/special-status.routes'
import companyFileRoutes from './routes/company-file.routes'
@@ -93,6 +97,10 @@ app.use('/api/v1/audit', auditRoutes)
app.use('/api/v1/calendar', calendarRoutes)
app.use('/api/v1/platform', platformRoutes)
app.use('/api/v1/work-processes', workProcessRoutes)
app.use('/api/v1/departments', departmentRoutes)
app.use('/api/v1/positions', positionRoutes)
app.use('/api/v1/approvals', approvalRoutes)
app.use('/api/v1/support', supportRoutes)
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
app.use('/api/v1/special-statuses', specialStatusRoutes)
app.use('/api/v1/company-files', companyFileRoutes)
+107
View File
@@ -0,0 +1,107 @@
/**
* 审批流路由
* 提供审批流配置和实例管理
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
import { processApproval, cancelApproval } from '../services/approval.service'
const router = Router()
const flowSchema = z.object({
type: z.string(), // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
name: z.string().min(1),
enabled: z.boolean().default(true),
steps: z.array(z.object({
step: z.number().int().min(1).max(3),
approverType: z.enum(['SUPERVISOR', 'DEPT_HEAD', 'PERSON']),
approverId: z.string().optional(),
name: z.string(),
})).min(1, '至少一个审批步骤').max(3, '最多三个审批步骤'),
})
/** 获取审批流配置列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const flows = await prisma.approvalFlow.findMany({
where: { orgId: req.user!.orgId! },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: flows })
} catch (err) {
next(err)
}
})
/** 创建/更新审批流配置(upsert by type */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = flowSchema.parse(req.body)
const existing = await prisma.approvalFlow.findFirst({
where: { orgId: req.user!.orgId!, type: data.type },
})
let flow
if (existing) {
flow = await prisma.approvalFlow.update({
where: { id: existing.id },
data: { ...data, createdBy: req.user!.id },
})
} else {
flow = await prisma.approvalFlow.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
}
res.json({ success: true, data: flow })
} catch (err) {
next(err)
}
})
/** 获取待我审批的实例 */
router.get('/pending', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const instances = await prisma.approvalInstance.findMany({
where: { orgId: req.user!.orgId!, status: 'PENDING' },
orderBy: { createdAt: 'desc' },
include: { employee: { select: { id: true, name: true, department: true } } },
})
res.json({ success: true, data: instances })
} catch (err) {
next(err)
}
})
/** 处理审批 */
router.post('/:id/process', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { result, comment } = req.body as { result: 'APPROVED' | 'REJECTED'; comment?: string }
if (!result || !['APPROVED', 'REJECTED'].includes(result)) {
throw { code: 'VALIDATION_ERROR', message: 'result 必须为 APPROVED 或 REJECTED' }
}
const approverName = req.user!.id || '审批人'
const outcome = await processApproval(req.user!.orgId!, id, req.user!.id, approverName, result, comment)
res.json({ success: true, data: outcome })
} catch (err) {
next(err)
}
})
/** 取消审批 */
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await cancelApproval(req.user!.orgId!, id)
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+101
View File
@@ -0,0 +1,101 @@
/**
* 部门管理路由
* 提供部门的增删改查(树形结构)
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const createDeptSchema = z.object({
name: z.string().min(1, '部门名称必填'),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().default(0),
description: z.string().max(200).optional(),
})
const updateDeptSchema = createDeptSchema.partial()
/** 获取部门树 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const departments = await prisma.department.findMany({
where: { orgId: req.user!.orgId! },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: { _count: { select: { employees: true, positions: true } } },
})
res.json({ success: true, data: departments })
} catch (err) {
next(err)
}
})
/** 创建部门 */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createDeptSchema.parse(req.body)
let level = 0
if (data.parentId) {
const parent = await prisma.department.findFirst({ where: { id: data.parentId, orgId: req.user!.orgId! } })
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
level = parent.level + 1
}
const dept = await prisma.department.create({
data: {
...data,
level,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: dept })
} catch (err) {
next(err)
}
})
/** 更新部门 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = updateDeptSchema.parse(req.body)
// 防止循环引用
if (data.parentId === id) throw { code: 'VALIDATION_ERROR', message: '不能将自身设为父部门' }
let level: number | undefined
if (data.parentId) {
const parent = await prisma.department.findFirst({ where: { id: data.parentId, orgId: req.user!.orgId! } })
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
level = parent.level + 1
} else if (data.parentId === null) {
level = 0
}
const dept = await prisma.department.update({
where: { id },
data: { ...data, level },
})
res.json({ success: true, data: dept })
} catch (err) {
next(err)
}
})
/** 删除部门 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
// 检查是否有子部门
const children = await prisma.department.findFirst({ where: { parentId: id, orgId: req.user!.orgId! } })
if (children) throw { code: 'VALIDATION_ERROR', message: '请先删除子部门' }
// 检查是否有关联员工
const employees = await prisma.employee.findFirst({ where: { departmentId: id, orgId: req.user!.orgId! } })
if (employees) throw { code: 'VALIDATION_ERROR', message: '该部门下仍有员工,无法删除' }
await prisma.department.delete({ where: { id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+22 -1
View File
@@ -3,7 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto'
import { sha256, decrypt } from '../lib/crypto'
import {
createEmployeeSchema,
updateEmployeeSchema,
@@ -116,6 +116,27 @@ router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next)
}
})
// 手机号查重
router.get('/check-phone', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const phone = req.query.phone as string
if (!phone || phone.length < 11) {
return res.json({ success: true, data: { exists: false } })
}
// 手机号加密存储,需遍历匹配(量小可接受)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId },
select: { id: true, name: true, department: true, status: true, phone: true },
})
const matched = employees.find(e => {
try { return e.phone ? decrypt(e.phone) === phone : false } catch { return false }
})
res.json({ success: true, data: { exists: !!matched, employee: matched ? { id: matched.id, name: matched.name, department: matched.department, status: matched.status } : undefined } })
} catch (err) {
next(err)
}
})
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createEmployeeSchema.parse(req.body)
+2 -2
View File
@@ -287,7 +287,7 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
{ header: '状态', key: 'status', width: 8 },
{ header: '入职日期', key: 'hireDate', width: 12 },
{ header: '手机号', key: 'phone', width: 13 },
{ header: '身份证号', key: 'idCardNumber', width: 20 },
{ header: '证件号码', key: 'idCardNumber', width: 20 },
{ header: '月工资', key: 'monthlySalary', width: 10 },
{ header: '社保基数', key: 'socialInsBase', width: 10 },
{ header: '公积金基数', key: 'housingFundBase', width: 10 },
@@ -478,7 +478,7 @@ router.get('/tax-declaration', authMiddleware, requireAdmin, async (req: AuthReq
let seq = 0
for (const e of entries) {
seq++
// 解密身份证号
// 解密证件号码
let idCard: string = ''
try { if (e.employee.idCardNumber) idCard = decrypt(e.employee.idCardNumber) || '' } catch { idCard = e.employee.idCardNumber || '' }
+40 -40
View File
@@ -18,17 +18,17 @@ function contentDisposition(filename: string): string {
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
// 身份证号格式校验(18位正则 + 校验位算法)
// 证件号码格式校验(18位正则 + 校验位算法)
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
if (!idCard) return { valid: true }
const s = idCard.trim()
// 15位身份证号升级为18位
// 15位证件号码升级为18位
if (/^\d{15}$/.test(s)) {
const upgraded = upgrade15To18(s)
return { valid: true, upgraded }
}
if (!/^\d{17}[\dXx]$/.test(s)) {
return { valid: false, error: '身份证号格式错误(应为18位)' }
return { valid: false, error: '证件号码格式错误(应为18位)' }
}
// 校验位算法
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
@@ -36,7 +36,7 @@ function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; er
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
const expected = checkCodes[sum % 11]
if (s.charAt(17).toUpperCase() !== expected) {
return { valid: false, error: '身份证号校验位错误' }
return { valid: false, error: '证件号码校验位错误' }
}
return { valid: true }
}
@@ -124,7 +124,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '证件号码')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(getField(r, '入职日期'))
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
@@ -144,8 +144,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(contractSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const sd = parseDate(getField(r, '合同开始日期'))
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
@@ -159,8 +159,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const otType = val(getField(r, '加班类型')) || '工作日加班'
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
@@ -173,8 +173,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
preview.disciplinary.push(row)
}
@@ -185,8 +185,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
@@ -259,8 +259,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const salary = String(num(getField(r, '月工资')))
if (salary === '0') { result.skipped++; result.errors.push(`员工第${i + 2}行:月工资为空`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '月工资为空' }); continue }
let idCard = val(getField(r, '身份证号'))
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:身份证号为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '身份证号为空' }); continue }
let idCard = val(getField(r, '证件号码'))
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:证件号码为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码为空' }); continue }
if (idCard) {
const idCheck = validateIdCard(idCard)
if (!idCheck.valid) { result.skipped++; result.errors.push(`员工第${i + 2}行:${idCheck.error}`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: idCheck.error }); continue }
@@ -329,8 +329,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const msg = e?.message || ''
if (msg.includes('Unique constraint')) {
result.duplicates++
result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '身份证号重复' })
result.errors.push(`员工第${i + 2}行:该员工已存在(证件号码重复),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '证件号码重复' })
} else if (msg.includes('invalid') || msg.includes('validation')) {
result.skipped++
result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
@@ -353,7 +353,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const startDate = parseDate(getField(r, '合同开始日期'))
@@ -399,7 +399,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
@@ -429,7 +429,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
@@ -462,7 +462,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
@@ -495,30 +495,30 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '证件号码': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '身份证号', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '证件号码', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
// 设置示例行样式(灰色背景)
empWs['!cols'] = [{ wch: 10 }, { wch: 12 }, { wch: 22 }, { wch: 13 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 6 }, { wch: 6 }, { wch: 6 }]
XLSX.utils.book_append_sheet(wb, empWs, '员工信息')
const contractData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
{ '姓名*': '张三', '证件号码': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
@@ -558,7 +558,7 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
function findEmp(r: any) {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
if (idCard) {
const emp = empByHash.get(sha256(idCard))
if (emp) return emp
@@ -778,10 +778,10 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号
// 合并考勤+加班为一个Sheet,减少重复录入姓名证件号码
const attOtData = [{
'姓名': '张三',
'身份证号': '110101199001011234',
'证件号码': '110101199001011234',
'日期': '2024-06-01',
'考勤状态': '正常',
'上班时间': '09:00',
@@ -798,16 +798,16 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
]
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
const salaryData = [{ '姓名': '张三', '证件号码': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
const socialData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
const hfData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
const discData = [{ '姓名': '张三', '证件号码': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
@@ -847,7 +847,7 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const entryId = entryByEmp.get(empId)
@@ -906,8 +906,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const data = [
{ '姓名*': '张三', '身份证号*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
{ '姓名*': '李四', '身份证号*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
{ '姓名*': '张三', '证件号码*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
{ '姓名*': '李四', '证件号码*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
@@ -921,8 +921,8 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const data = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
{ '姓名*': '李四', '身份证号': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
{ '姓名*': '张三', '证件号码': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
{ '姓名*': '李四', '证件号码': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
@@ -953,7 +953,7 @@ router.post('/special-deduction', authMiddleware, requireAdmin, upload.single('f
const r = rows[i] as any
try {
const name = val(getField(r, '姓名'))
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(name)
if (!empId) { result.skipped++; result.errors.push(`${i + 2}行:找不到员工「${name}`); result.details.push({ row: i + 2, name, status: 'skipped', message: '找不到员工' }); continue }
+37 -7
View File
@@ -283,7 +283,18 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
})
employees = terminations.map(t => t.employee)
// SEVERANCE 批次:仅包含已审批通过(APPROVED/EXECUTING/COMPLETED)且有补偿金的离职记录
if (type === 'SEVERANCE') {
const eligibleTerms = terminations.filter(t =>
(t.status === 'APPROVED' || t.status === 'EXECUTING' || t.status === 'COMPLETED') &&
(t.compensation > 0 || (t.compensationBreakdown as any)?.total > 0)
)
employees = eligibleTerms.map(t => t.employee)
// 缓存 terminationRecord 以便后续 entry 创建时读取补偿金
;(req as any)._severanceTerms = new Map(eligibleTerms.map(t => [t.employeeId, t]))
} else {
employees = terminations.map(t => t.employee)
}
} else {
employees = await prisma.employee.findMany({
where: {
@@ -357,14 +368,33 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
// SEVERANCE 批次:从离职记录读取补偿金作为应发金额,不走工资/社保/个税计算
let severanceAmount = 0
let severanceBreakdown: any = null
if (type === 'SEVERANCE') {
const severanceTerms: Map<string, any> = (req as any)._severanceTerms || new Map()
const termRecord = severanceTerms.get(emp.id)
if (termRecord) {
severanceBreakdown = termRecord.compensationBreakdown
// 优先取 compensationBreakdown.total(含手动调整),否则取 compensation
severanceAmount = (severanceBreakdown as any)?.total || termRecord.compensation || 0
baseSalary = severanceAmount
}
}
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
// SEVERANCE 批次:补偿金不走社保/个税计算,直接作为应发和实发金额
let calcResult: any
try {
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
} catch (calcErr: any) {
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction }
if (type === 'SEVERANCE' && severanceAmount > 0) {
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: severanceAmount, netPay: severanceAmount }
} else {
try {
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
} catch (calcErr: any) {
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction }
}
}
// 风险提示
+1 -1
View File
@@ -940,7 +940,7 @@ router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, nex
}
} else {
content = `<h1>解除/终止劳动合同证明书</h1>
<p>兹证明 ${variables.employeeName}身份证号${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate}${reason} 原因,正式解除/终止劳动合同。</p>
<p>兹证明 ${variables.employeeName}证件号码${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate}${reason} 原因,正式解除/终止劳动合同。</p>
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
<p>特此证明。</p>
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
+74
View File
@@ -0,0 +1,74 @@
/**
* 岗位字典路由
* 提供岗位的增删改查
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const createPositionSchema = z.object({
name: z.string().min(1, '岗位名称必填'),
departmentId: z.string().nullable().optional(),
headcount: z.number().int().min(0).default(0),
level: z.string().max(20).optional(),
description: z.string().max(200).optional(),
})
/** 获取岗位列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const positions = await prisma.position.findMany({
where: { orgId: req.user!.orgId! },
orderBy: { createdAt: 'asc' },
include: { department: { select: { id: true, name: true } } },
})
res.json({ success: true, data: positions })
} catch (err) {
next(err)
}
})
/** 创建岗位 */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createPositionSchema.parse(req.body)
const position = await prisma.position.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: position })
} catch (err) {
next(err)
}
})
/** 更新岗位 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = createPositionSchema.partial().parse(req.body)
const position = await prisma.position.update({ where: { id }, data })
res.json({ success: true, data: position })
} catch (err) {
next(err)
}
})
/** 删除岗位 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await prisma.position.delete({ where: { id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+4 -4
View File
@@ -94,7 +94,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
whereBase.contracts = { none: {} }
}
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页
// 当有 contractStatus(非 unsigned)筛选或证件号码搜索时,需要先查全部再过滤后分页
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
const [dbTotal, employees] = await Promise.all([
@@ -170,7 +170,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > todayEnd
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
// 身份证号脱敏显示
// 证件号码脱敏显示
let idCardMasked: string | null = null
if (e.idCardNumber) {
try {
@@ -250,7 +250,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
// 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
if (isIdCardSearch) {
result = result.filter((e: any) => {
if (!e.idCardNumber) return false
@@ -1025,7 +1025,7 @@ router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, as
const content = `违纪确认证明
兹证明 ${record.employee.name}身份证号${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
兹证明 ${record.employee.name}证件号码${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
违纪类型:${typeMap[record.violationType] || record.violationType}
严重程度:${severityMap[record.severity] || record.severity}
+237
View File
@@ -0,0 +1,237 @@
/**
* 客服工作台路由
* 提供工单管理、客户会话、租户数据穿透
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
// 客服权限中间件
const supportMiddleware = (req: AuthRequest, res: any, next: any) => {
if (req.user!.role !== 'SUPPORT' && req.user!.role !== 'SUPER_ADMIN') {
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '仅客服或超级管理员可访问' } })
}
next()
}
const createTicketSchema = z.object({
title: z.string().min(1, '标题必填'),
content: z.string().min(1, '内容必填'),
category: z.string().optional(),
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
})
const createMessageSchema = z.object({
content: z.string().min(1, '内容必填'),
})
// ==================== 工单管理 ====================
/** 获取工单列表(客服看全部,企业用户看自己租户的) */
router.get('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const where = isSupport ? {} : { orgId: req.user!.orgId! }
const tickets = await prisma.ticket.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
org: { select: { id: true, name: true } },
_count: { select: { messages: true } },
},
})
res.json({ success: true, data: tickets })
} catch (err) {
next(err)
}
})
/** 获取工单详情 */
router.get('/tickets/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const ticket = await prisma.ticket.findUnique({
where: { id },
include: {
org: { select: { id: true, name: true } },
messages: { orderBy: { createdAt: 'asc' } },
},
})
if (!ticket) throw { code: 'NOT_FOUND', message: '工单不存在' }
res.json({ success: true, data: ticket })
} catch (err) {
next(err)
}
})
/** 创建工单(企业用户提交) */
router.post('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createTicketSchema.parse(req.body)
const ticket = await prisma.ticket.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: ticket })
} catch (err) {
next(err)
}
})
/** 回复工单 */
router.post('/tickets/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = createMessageSchema.parse(req.body)
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const message = await prisma.ticketMessage.create({
data: {
ticketId: id,
content: data.content,
senderId: req.user!.id,
senderRole: isSupport ? 'SUPPORT' : 'USER',
},
})
// 客服回复时更新工单状态为处理中
if (isSupport) {
await prisma.ticket.update({ where: { id }, data: { status: 'IN_PROGRESS', assigneeId: req.user!.id } })
}
res.json({ success: true, data: message })
} catch (err) {
next(err)
}
})
/** 关闭工单 */
router.post('/tickets/:id/close', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await prisma.ticket.update({ where: { id }, data: { status: 'CLOSED' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 转派工单 */
router.post('/tickets/:id/assign', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { assigneeId } = req.body
await prisma.ticket.update({ where: { id }, data: { assigneeId } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ==================== 客户会话 ====================
/** 获取会话列表 */
router.get('/chat/sessions', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const where = isSupport ? { supportUserId: req.user!.id } : { orgId: req.user!.orgId! }
const sessions = await prisma.chatSession.findMany({
where,
orderBy: { lastMessageAt: 'desc' },
include: { org: { select: { id: true, name: true } } },
})
res.json({ success: true, data: sessions })
} catch (err) {
next(err)
}
})
/** 获取会话消息 */
router.get('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const messages = await prisma.chatMessage.findMany({
where: { sessionId: id },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: messages })
} catch (err) {
next(err)
}
})
/** 发送会话消息 */
router.post('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { content } = req.body as { content: string }
if (!content) throw { code: 'VALIDATION_ERROR', message: '内容必填' }
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const session = await prisma.chatSession.findUnique({ where: { id } })
if (!session) throw { code: 'NOT_FOUND', message: '会话不存在' }
const message = await prisma.chatMessage.create({
data: {
sessionId: id,
content,
senderId: req.user!.id,
senderRole: isSupport ? 'SUPPORT' : 'USER',
},
})
await prisma.chatSession.update({
where: { id },
data: {
lastMessage: content,
lastMessageAt: new Date(),
unreadBySupport: isSupport ? session.unreadBySupport : session.unreadBySupport + 1,
unreadByUser: isSupport ? session.unreadByUser + 1 : session.unreadByUser,
},
})
res.json({ success: true, data: message })
} catch (err) {
next(err)
}
})
// ==================== 租户数据穿透 ====================
/** 获取租户列表(客服用) */
router.get('/tenants', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const tenants = await prisma.organization.findMany({
select: {
id: true, name: true, plan: true, maxEmployees: true,
contactName: true, contactPhone: true, createdAt: true,
_count: { select: { employees: true, users: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: tenants })
} catch (err) {
next(err)
}
})
/** 获取租户数据概览(客服代查看) */
router.get('/tenants/:orgId/overview', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { orgId } = req.params
const [employeeCount, activeContracts, pendingApprovals, openTickets] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
prisma.laborContract.count({ where: { orgId } }),
prisma.approvalInstance.count({ where: { orgId, status: 'PENDING' } }),
prisma.ticket.count({ where: { orgId, status: { in: ['OPEN', 'IN_PROGRESS'] } } }),
])
res.json({
success: true,
data: { employeeCount, activeContracts, pendingApprovals, openTickets },
})
} catch (err) {
next(err)
}
})
export default router
+28 -1
View File
@@ -27,7 +27,7 @@ const REQUIRED_FIELDS: Record<string, string[]> = {
// 必填字段中文标签映射
const FIELD_LABELS: Record<string, string> = {
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
idCardNumber: '身份证号', employeeId: '员工ID', contractId: '合同ID',
idCardNumber: '证件号码', employeeId: '员工ID', contractId: '合同ID',
contractStartDate: '合同开始日期', confirmDate: '转正日期',
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
suspendDate: '中止日期', employeeName: '员工姓名',
@@ -35,11 +35,38 @@ const FIELD_LABELS: Record<string, string> = {
leaveDate: '离职日期', agreementStartDate: '协议开始日期',
}
/** 日期字段对配置:各流程类型的开始/结束日期字段对 */
const DATE_RANGE_FIELDS: Record<string, Array<{ start: string; end: string; startLabel: string; endLabel: string }>> = {
HIRE: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
CUSTOM_CONTRACT: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
RENEW: [{ start: 'newStartDate', end: 'newEndDate', startLabel: '新合同开始日期', endLabel: '新合同结束日期' }],
FLEXIBLE: [{ start: 'agreementStartDate', end: 'agreementEndDate', startLabel: '协议开始日期', endLabel: '协议结束日期' }],
}
/** 校验日期前后关系:结束日期不能早于开始日期 */
function validateWorkProcessDateRange(type: string, formData: Record<string, any>): string | null {
const pairs = DATE_RANGE_FIELDS[type]
if (!pairs) return null
for (const pair of pairs) {
const start = formData[pair.start]
const end = formData[pair.end]
if (start && end && new Date(end) < new Date(start)) {
return `${pair.endLabel}不能早于${pair.startLabel}`
}
}
return null
}
// 创建办理(含草稿)
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createWorkProcessSchema.parse(req.body)
const { type, title, employeeId, formData, status, remark } = data
// 后端日期前后关系校验(双保险)
const dateError = validateWorkProcessDateRange(type, formData || {})
if (dateError) {
return res.status(400).json({ success: false, error: { code: 'INVALID_DATE_RANGE', message: dateError } })
}
const process = await (prisma as any).workProcess.create({
data: {
orgId: req.user!.orgId,
+3 -1
View File
@@ -5,7 +5,7 @@ export const createEmployeeSchema = z.object({
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
hireDate: z.string().datetime(),
monthlySalary: z.string().min(1, '月薪不能为空'),
idCardNumber: z.string().min(18, '身份证号不能为空且必须18位').max(18, '身份证号必须18位'),
idCardNumber: z.string().min(18, '证件号码不能为空且必须18位').max(18, '证件号码必须18位'),
gender: z.enum(['男', '女']).optional(),
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
@@ -50,6 +50,8 @@ export const updateEmployeeSchema = z.object({
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
position: z.string().max(50).optional(),
status: z.enum(['ACTIVE', 'PENDING_ONBOARD', 'RESIGNED', 'BLACKLISTED']).optional(),
cityChangeReason: z.string().max(200).optional(),
})
export const batchRenewSchema = z.object({
+162
View File
@@ -0,0 +1,162 @@
/**
* 审批流引擎
* 支持最多 3 步审批(发起人 → 直属上级 → 部门负责人)
*/
import prisma from '../lib/prisma'
interface ApprovalStep {
step: number
approverType: 'SUPERVISOR' | 'DEPT_HEAD' | 'PERSON'
approverId?: string
name: string
}
interface ApprovalRecord {
step: number
approverId: string
approverName: string
result: 'APPROVED' | 'REJECTED'
comment?: string
timestamp: string
}
/**
* 创建审批实例
*/
export async function createApprovalInstance(
orgId: string,
userId: string,
type: string,
bizId: string,
bizType: string,
employeeId: string,
): Promise<{ instance: any; firstApprover?: any }> {
// 查找该类型的审批流配置
const flow = await prisma.approvalFlow.findFirst({
where: { orgId, type, enabled: true },
})
if (!flow) {
// 无审批流配置,直接通过
return { instance: null }
}
const steps = flow.steps as unknown as ApprovalStep[]
if (!steps || steps.length === 0) {
return { instance: null }
}
const instance = await prisma.approvalInstance.create({
data: {
orgId,
flowId: flow.id,
type,
bizId,
bizType,
status: 'PENDING',
currentStep: 1,
approvals: [],
employeeId,
createdBy: userId,
},
})
// 计算第一步审批人
const firstApprover = await resolveApprover(orgId, employeeId, steps[0])
return { instance, firstApprover }
}
/**
* 根据步骤配置解析审批人
*/
async function resolveApprover(orgId: string, employeeId: string, step: ApprovalStep): Promise<any> {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { dept: true, supervisor: true },
})
if (!employee) return null
if (step.approverType === 'SUPERVISOR') {
return employee.supervisor ? { id: employee.supervisor.id, name: employee.supervisor.name } : null
} else if (step.approverType === 'DEPT_HEAD') {
// 部门负责人暂用部门创建人(简化实现)
if (employee.dept) {
return { id: employee.dept.createdBy, name: '部门负责人' }
}
return null
} else if (step.approverType === 'PERSON' && step.approverId) {
const approver = await prisma.employee.findFirst({ where: { id: step.approverId, orgId } })
return approver ? { id: approver.id, name: approver.name } : null
}
return null
}
/**
* 处理审批
*/
export async function processApproval(
orgId: string,
instanceId: string,
approverId: string,
approverName: string,
result: 'APPROVED' | 'REJECTED',
comment?: string,
): Promise<{ status: string; nextApprover?: any }> {
const instance = await prisma.approvalInstance.findFirst({
where: { id: instanceId, orgId },
include: { flow: true },
})
if (!instance) throw { code: 'NOT_FOUND', message: '审批实例不存在' }
if (instance.status !== 'PENDING') throw { code: 'VALIDATION_ERROR', message: '审批实例已处理' }
const steps = instance.flow.steps as unknown as ApprovalStep[]
const currentStepConfig = steps.find(s => s.step === instance.currentStep)
if (!currentStepConfig) throw { code: 'VALIDATION_ERROR', message: '步骤配置错误' }
// 记录审批结果
const approvals = (instance.approvals as unknown as ApprovalRecord[]) || []
approvals.push({
step: instance.currentStep,
approverId,
approverName,
result,
comment,
timestamp: new Date().toISOString(),
})
if (result === 'REJECTED') {
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'REJECTED', approvals: approvals as any },
})
return { status: 'REJECTED' }
}
// 查找下一步
const nextStepConfig = steps.find(s => s.step === instance.currentStep + 1)
if (!nextStepConfig) {
// 全部通过
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'APPROVED', approvals: approvals as any },
})
return { status: 'APPROVED' }
}
// 进入下一步
const nextApprover = await resolveApprover(orgId, instance.employeeId || '', nextStepConfig)
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { currentStep: instance.currentStep + 1, approvals: approvals as any },
})
return { status: 'PENDING', nextApprover }
}
/**
* 取消审批
*/
export async function cancelApproval(orgId: string, instanceId: string): Promise<void> {
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'CANCELLED' },
})
}
+4 -3
View File
@@ -31,7 +31,7 @@ async function clampHousingFundBase(orgId: string, base: number, city?: string):
return base
}
function prevMonth(month: string): string {
export 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')}`
@@ -209,14 +209,14 @@ export async function getEmployeeDetail(orgId: string, id: string) {
}
export async function createEmployee(orgId: string, userId: string, data: any) {
// 身份证号查重
// 证件号码查重
if (data.idCardNumber) {
const existing = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(data.idCardNumber) },
select: { id: true, name: true, department: true, status: true },
})
if (existing) {
throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}${existing.department}${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${existing.name}${existing.department}${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
}
}
@@ -591,6 +591,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
if (data.city !== undefined) updateData.city = data.city
if (data.education !== undefined) updateData.education = data.education
if (data.position !== undefined) updateData.position = data.position
if (data.status !== undefined) updateData.status = data.status
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
if (data.city !== undefined && data.city !== employee.city) {
+1 -1
View File
@@ -110,7 +110,7 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
{ title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '企业用工专家是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如身份证号都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如证件号码都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '可以导出数据吗', content: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' },
]
+2 -2
View File
@@ -1,7 +1,7 @@
import crypto from 'crypto'
import prisma from '../lib/prisma'
// 从身份证号提取出生日期
// 从证件号码提取出生日期
export function extractBirthDateFromIdCard(idCard: string): Date | null {
// 18位身份证:7-14位为出生日期 YYYYMMDD
if (idCard.length === 18) {
@@ -24,7 +24,7 @@ export function extractBirthDateFromIdCard(idCard: string): Date | null {
return null
}
// 从身份证号提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
// 从证件号码提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
export function extractGenderFromIdCard(idCard: string): string | null {
if (idCard.length === 18) {
const genderCode = parseInt(idCard.substring(16, 17))
+2 -2
View File
@@ -1928,7 +1928,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
}),
])
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId
// 按证件号码去重(同一人可能有多条 Employee 记录),无证件号码时回退到 employeeId
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
const personBreakdown: Record<string, {
personKey: string
@@ -1988,7 +1988,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
}
for (const r of employeeRiskDetails) {
// 去重优先级:身份证号 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
// 去重优先级:证件号码 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown'
if (!personBreakdown[personKey]) {
personBreakdown[personKey] = {
@@ -92,6 +92,11 @@ export async function createSpecialStatus(orgId: string, userId: string, data: a
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 合规校验:男职工不可选择三期
if (data.type === 'PREGNANCY' && employee.gender === '男') {
throw { code: 'VALIDATION_ERROR', message: '三期仅适用于女性员工,男职工不可选择三期' }
}
// 三期自动计算
let pregnancyData: any = {}
if (data.type === 'PREGNANCY' && data.expectedDueDate) {
+2 -2
View File
@@ -95,7 +95,7 @@ export const documentTemplates: DocumentTemplate[] = [
content: `解除劳动合同协议书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}身份证号{{idCard}}
乙方(劳动者):{{employeeName}}证件号码{{idCard}}
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
@@ -135,7 +135,7 @@ export const documentTemplates: DocumentTemplate[] = [
content: `解除劳动合同协议书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}身份证号{{idCard}}
乙方(劳动者):{{employeeName}}证件号码{{idCard}}
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
+38 -4
View File
@@ -1,7 +1,7 @@
import prisma from '../lib/prisma'
import { encrypt } from '../lib/crypto'
import { encrypt, decrypt } from '../lib/crypto'
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
import { createEmployee, addContract } from './contract.service'
import { createEmployee, addContract, prevMonth } from './contract.service'
import { runRiskDetection } from './risk.service'
// 13类流程定义
@@ -69,10 +69,44 @@ export async function executeWorkProcess(processId: string, type: string, formDa
const { employeeId, regularSalary } = formData
if (employeeId) {
if (regularSalary) {
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
const newSalary = Number(regularSalary) || 0
await prisma.employee.update({
where: { id: employeeId },
data: { monthlySalary: encrypt(String(regularSalary)) },
})
// 记录薪资变更(试用期薪资 → 转正薪资)
if (oldSalary !== newSalary) {
const now = new Date()
const nowMonth = now.toISOString().slice(0, 7)
await prisma.salaryChangeRecord.updateMany({
where: { employeeId, endMonth: null },
data: { endMonth: prevMonth(nowMonth) },
})
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId,
oldSalary,
newSalary,
effectiveDate: now,
effectiveMonth: nowMonth,
endMonth: null,
changeType: 'CONFIRM',
reason: '试用期转正薪资调整',
createdBy: userId,
},
})
}
// 校验转正薪资与最新合同试用期薪资是否一致(提示性校验)
const latestContract = await prisma.laborContract.findFirst({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
if (latestContract?.probationSalary && latestContract.probationSalary !== newSalary) {
console.warn(`[CONFIRM] 转正薪资 ¥${newSalary} 与合同试用期薪资 ¥${latestContract.probationSalary} 不一致,员工: ${employeeId}`)
}
await runRiskDetection(orgId)
}
}
@@ -275,13 +309,13 @@ ${body}
const templates: Record<string, (data: any, org: string) => string> = {
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
<div class="body">兹证明 ${data.employeeName || '___'}身份证号${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
<div class="body">兹证明 ${data.employeeName || '___'}证件号码${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
<div class="body">该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。</div>
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
<div class="body">特此证明。</div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
<div class="body">兹证明 ${data.employeeName || '___'}身份证号${data.idCardNumber || '___'})自 ${data.hireDate || '___'}${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
<div class="body">兹证明 ${data.employeeName || '___'}证件号码${data.idCardNumber || '___'})自 ${data.hireDate || '___'}${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
<div class="body">该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
<div class="body">特此证明。</div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
+4 -4
View File
@@ -16,7 +16,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.0",
"mammoth": "^1.12.1",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -3348,9 +3348,9 @@
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"version": "1.12.1",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.1.tgz",
"integrity": "sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
+1 -1
View File
@@ -17,7 +17,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.0",
"mammoth": "^1.12.1",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+4
View File
@@ -19,6 +19,8 @@ const Dashboard = lazy(() => import('./pages/Dashboard'))
const Money = lazy(() => import('./pages/Money'))
const SocialInsurance = lazy(() => import('./pages/SocialInsurance'))
const Roster = lazy(() => import('./pages/Roster'))
const OrgChart = lazy(() => import('./pages/OrgChart'))
const SupportDashboard = lazy(() => import('./pages/support/SupportDashboard'))
const Termination = lazy(() => import('./pages/Termination'))
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
const Settings = lazy(() => import('./pages/Settings'))
@@ -185,6 +187,8 @@ export default function App() {
{/* 管理端业务页面 */}
<Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} />
<Route path="/org-chart" element={<ProtectedRoute><AdminLayout><OrgChart /></AdminLayout></ProtectedRoute>} />
<Route path="/support" element={<ProtectedRoute><AdminLayout><SupportDashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></AdminLayout></ProtectedRoute>} />
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
+7 -7
View File
@@ -139,13 +139,13 @@ const categories: HelpCategory[] = [
{
id: 'update-termination-cert',
question: '修复:离职证明下载内容为乱码',
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、身份证号、入职日期、离职日期、企业名称等',
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、证件号码、入职日期、离职日期、企业名称等',
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明',
},
{
id: 'update-attendance-template',
question: '优化:考勤导入模板合并Sheet,减少重复录入',
answer: '考勤月度导入模板从多个Sheet合并为单Sheet:\n• 考勤记录和加班记录合并为「考勤与加班」一个Sheet\n• 不再需要分别填写考勤Sheet和加班Sheet\n• 导入时自动识别合并Sheet或独立Sheet,兼容旧模板\n• 减少重复录入姓名和身份证号',
answer: '考勤月度导入模板从多个Sheet合并为单Sheet:\n• 考勤记录和加班记录合并为「考勤与加班」一个Sheet\n• 不再需要分别填写考勤Sheet和加班Sheet\n• 导入时自动识别合并Sheet或独立Sheet,兼容旧模板\n• 减少重复录入姓名和证件号码',
tip: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板',
},
{
@@ -240,14 +240,14 @@ const categories: HelpCategory[] = [
},
{
id: 'update-idcard-dedup',
question: '新增:用工办理按身份证号查重',
answer: '创建员工时自动按身份证号查重:\n• 后端创建员工前先检查身份证号是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
question: '新增:用工办理按证件号码查重',
answer: '创建员工时自动按证件号码查重:\n• 后端创建员工前先检查证件号码是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工',
},
{
id: 'update-income-cert-select',
question: '优化:收入证明支持员工下拉选择并自动填充',
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、身份证号、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、证件号码、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工',
},
{
@@ -496,7 +496,7 @@ const categories: HelpCategory[] = [
id: 'attendance-import',
question: '怎么导入考勤数据?',
answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。',
tip: '身份证号优先匹配,未填时用姓名匹配。',
tip: '证件号码优先匹配,未填时用姓名匹配。',
},
{
id: 'attendance-correct',
@@ -613,7 +613,7 @@ const categories: HelpCategory[] = [
{
id: 'data-safe',
question: '我的数据安全吗?',
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如身份证号)都经过加密存储。',
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如证件号码)都经过加密存储。',
},
{
id: 'data-export',
@@ -44,7 +44,7 @@ const navGroups: NavGroup[] = [
title: '团队',
items: [
{ path: '/roster', label: '花名册', icon: Users },
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
{ path: '/org-chart', label: '组织架构', icon: Building2 },
{ path: '/termination', label: '离职管理', icon: UserX },
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
+1 -1
View File
@@ -274,7 +274,7 @@ export const surveyPages: SurveyPage[] = [
{
id: 24, name: 'Onboarding(入职信息填报)', file: 'frontend/src/pages/portal/Onboarding.tsx', menu: '员工端 > 入职填报',
features: [
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、身份证号、紧急联系人等' },
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、证件号码、紧急联系人等' },
{ id: '24.2', name: '入职材料上传', desc: '上传身份证、学历证明、银行卡等材料' },
{ id: '24.3', name: '文件类型选择', desc: '选择上传文件的类型(身份证正反面、学历、银行卡、其他)' },
{ id: '24.4', name: '已传文件管理', desc: '查看和删除已上传文件' },
+3
View File
@@ -69,6 +69,9 @@ export const employeeApi = {
/** 身份证查重 */
checkIdCard: (idCard: string) =>
get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()),
/** 手机号查重 */
checkPhone: (phone: string) =>
get('/employees/check-phone', { params: { phone } }).then(unwrap<{ exists: boolean; employee?: any }>()),
/** 更新员工 */
update: (id: string, data: Record<string, unknown>) =>
put(`/employees/${id}`, data),
+1 -1
View File
@@ -555,7 +555,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
</div>
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
Sheet 0
Sheet 0
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
+1 -1
View File
@@ -78,7 +78,7 @@ export default function Contracts() {
<FileText className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<p className="mt-1 text-sm text-gray-500">//</p>
</div>
</div>
<Button onClick={() => setShowAddModal(true)}>
+294
View File
@@ -0,0 +1,294 @@
/**
*
* +
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Building2, Briefcase } from 'lucide-react'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import api from '../lib/api'
export default function OrgChart() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<'dept' | 'position'>('dept')
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<any>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [form, setForm] = useState<any>({ name: '', parentId: '', sortOrder: 0, description: '' })
const { data: departments = [], isLoading } = useQuery({
queryKey: ['departments'],
queryFn: () => api.get('/departments').then(r => r.data.data),
})
const { data: positions = [] } = useQuery({
queryKey: ['positions'],
queryFn: () => api.get('/positions').then(r => r.data.data),
enabled: tab === 'position',
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post(editing ? `/departments/${editing.id}` : '/departments', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] })
setShowModal(false)
toast.success(editing ? '部门已更新' : '部门已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/departments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] })
toast.success('部门已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
const toggleExpand = (id: string) => {
setExpanded(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const renderTree = (items: any[], parentId: string | null, level: number = 0) => {
return items.filter(d => d.parentId === parentId).map(d => (
<div key={d.id}>
<div
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 rounded-md"
style={{ marginLeft: level * 24 }}
>
{items.some(c => c.parentId === d.id) ? (
<button onClick={() => toggleExpand(d.id)} className="text-gray-400 hover:text-gray-600">
{expanded.has(d.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
) : (
<span className="w-4" />
)}
<Building2 className="w-4 h-4 text-primary" />
<span className="flex-1 text-sm">{d.name}</span>
<span className="text-xs text-gray-400">{d._count?.employees || 0}</span>
<button
onClick={() => {
setEditing(d)
setForm({ name: d.name, parentId: d.parentId || '', sortOrder: d.sortOrder, description: d.description || '' })
setShowModal(true)
}}
className="text-gray-400 hover:text-primary"
>
<Edit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { if (confirm(`确认删除部门"${d.name}"`)) deleteMutation.mutate(d.id) }}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{expanded.has(d.id) && renderTree(items, d.id, level + 1)}
</div>
))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
<div className="flex gap-2 border-b">
<button
onClick={() => setTab('dept')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'dept' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
>
</button>
<button
onClick={() => setTab('position')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'position' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
>
</button>
</div>
{tab === 'dept' ? (
<Card className="p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium"></span>
<Button size="sm" onClick={() => {
setEditing(null)
setForm({ name: '', parentId: '', sortOrder: 0, description: '' })
setShowModal(true)
}}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">...</div>
) : departments.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-1">{renderTree(departments, null)}</div>
)}
</Card>
) : (
<PositionTab positions={positions} departments={departments} />
)}
{showModal && (
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:技术部" />
</div>
<div>
<Label></Label>
<Select value={form.parentId} onChange={(e) => setForm({ ...form, parentId: e.target.value })}>
<option value=""></option>
{departments.filter((d: any) => d.id !== editing?.id).map((d: any) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowModal(false)}></Button>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
{editing ? '保存' : '创建'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
/** 岗位字典子组件 */
function PositionTab({ positions, departments }: { positions: any[]; departments: any[] }) {
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<any>(null)
const [form, setForm] = useState<any>({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(editing ? `/positions/${editing.id}` : '/positions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
setShowModal(false)
toast.success(editing ? '岗位已更新' : '岗位已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/positions/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
toast.success('岗位已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
return (
<Card className="p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium"></span>
<Button size="sm" onClick={() => {
setEditing(null)
setForm({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
setShowModal(true)
}}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{positions.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-1">
{positions.map(p => (
<div key={p.id} className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 rounded-md">
<Briefcase className="w-4 h-4 text-primary" />
<span className="flex-1 text-sm">{p.name}</span>
{p.department && <span className="text-xs text-gray-400">{p.department.name}</span>}
{p.level && <span className="text-xs text-gray-400">{p.level}</span>}
<span className="text-xs text-gray-400">{p.headcount}</span>
<button
onClick={() => {
setEditing(p)
setForm({ name: p.name, departmentId: p.departmentId || '', headcount: p.headcount, level: p.level || '', description: p.description || '' })
setShowModal(true)
}}
className="text-gray-400 hover:text-primary"
>
<Edit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { if (confirm(`确认删除岗位"${p.name}"`)) deleteMutation.mutate(p.id) }}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{showModal && (
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑岗位' : '新增岗位'}>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:前端工程师" />
</div>
<div>
<Label></Label>
<Select value={form.departmentId} onChange={(e) => setForm({ ...form, departmentId: e.target.value })}>
<option value=""></option>
{departments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.headcount} onChange={(e) => setForm({ ...form, headcount: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input value={form.level} onChange={(e) => setForm({ ...form, level: e.target.value })} placeholder="如 P1/M1" />
</div>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowModal(false)}></Button>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
{editing ? '保存' : '创建'}
</Button>
</div>
</div>
</Modal>
)}
</Card>
)
}
+134 -17
View File
@@ -5,8 +5,8 @@ import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2, CheckCircle, FileText } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services'
import { copyToClipboard } from '../lib/clipboard'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
@@ -19,7 +19,7 @@ import { fmt, terminateReasonMap } from './roster/shared'
import PageGuide from '../components/ui/PageGuide'
import EmployeeProfile from './roster/EmployeeProfile'
import { InlineAlert } from '../components/ui/InlineAlert'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal, ConfirmModal } from './roster/modals'
import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError'
@@ -77,6 +77,8 @@ export default function Roster() {
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [confirmEmployee, setConfirmEmployee] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -210,6 +212,21 @@ export default function Roster() {
},
})
// 转正 mutation
const confirmMutation = useMutation({
mutationFn: (data: { employeeId: string; confirmDate: string; regularSalary?: number }) =>
workProcessApi.create({ type: 'CONFIRM', title: '转正', employeeId: data.employeeId, formData: { employeeId: data.employeeId, confirmDate: data.confirmDate, regularSalary: data.regularSalary }, status: 'DRAFT' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowConfirmModal(false)
setConfirmEmployee(null)
toast.success('转正已提交')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '转正失败'),
})
const batchRenewMutation = useMutation({
mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data),
onSuccess: () => {
@@ -264,6 +281,49 @@ export default function Roster() {
},
})
/** 批量转正:为选中的试用期员工创建转正草稿 */
const handleBatchConfirm = async () => {
const probationEmployees = employees.filter((e: any) => selectedIds.has(e.id) && e.probationInfo?.isProbation)
if (probationEmployees.length === 0) {
toast.error('选中的员工中没有试用期员工')
return
}
const confirmDate = new Date().toISOString().slice(0, 10)
let success = 0
let failed = 0
await Promise.all(
probationEmployees.map(async (emp: any) => {
try {
await workProcessApi.create({
type: 'CONFIRM',
title: '批量转正',
employeeId: emp.id,
formData: { employeeId: emp.id, confirmDate },
status: 'DRAFT',
})
success++
} catch {
failed++
}
})
)
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
if (success > 0) toast.success(`已为 ${success} 名员工创建转正草稿`)
if (failed > 0) toast.error(`${failed} 名员工转正失败`)
setSelectedIds(new Set())
}
/** 批量开具证明:跳转到用工办理批量开具收入证明 */
const handleBatchCert = () => {
if (selectedIds.size === 0) {
toast.error('请至少选择一名员工')
return
}
const ids = Array.from(selectedIds).join(',')
window.location.href = `/work-process?type=INCOME_CERT&employeeIds=${ids}`
}
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
@@ -450,6 +510,12 @@ export default function Roster() {
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
<Check className="mr-1 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={handleBatchConfirm} className="shrink-0">
<CheckCircle className="mr-1 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={handleBatchCert} className="shrink-0">
<FileText className="mr-1 h-4 w-4" />
</Button>
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
<UserX className="mr-1 h-4 w-4" />
</Button>
@@ -540,10 +606,10 @@ export default function Roster() {
</td>
<td className="px-4 py-3 font-medium">
<div>{e.name}</div>
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整证件号码" onClick={(ev) => {
ev.stopPropagation()
if (e.idCardNumber) {
copyToClipboard(e.idCardNumber, '已复制身份证号')
copyToClipboard(e.idCardNumber, '已复制证件号码')
}
}}>{e.idCardMasked || '—'}</div>
</td>
@@ -713,18 +779,35 @@ export default function Roster() {
>
<DollarSign className="h-4 w-4" />
</button>
<button
type="button"
title="发薪"
aria-label={`${e.name}发薪`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
navigate('/money')
}}
>
<Wallet className="h-4 w-4" />
</button>
{/* 试用期员工显示转正按钮,其他员工显示发薪按钮 */}
{e.probationInfo?.isProbation ? (
<button
type="button"
title="转正"
aria-label={`${e.name}办理转正`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
setConfirmEmployee(e)
setShowConfirmModal(true)
}}
>
<CheckCircle className="h-4 w-4" />
</button>
) : (
<button
type="button"
title="发薪"
aria-label={`${e.name}发薪`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
navigate('/money')
}}
>
<Wallet className="h-4 w-4" />
</button>
)}
<button
type="button"
title="调部门"
@@ -738,6 +821,30 @@ export default function Roster() {
>
<Building2 className="h-4 w-4" />
</button>
<button
type="button"
title="开具证明"
aria-label={`${e.name}开具证明`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
window.location.href = `/work-process?type=INCOME_CERT&employeeId=${e.id}`
}}
>
<FileText className="h-4 w-4" />
</button>
<button
type="button"
title="续签合同"
aria-label={`${e.name}续签合同`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
window.location.href = `/work-process?type=RENEW&employeeId=${e.id}`
}}
>
<RotateCcw className="h-4 w-4" />
</button>
<button
type="button"
title="离职"
@@ -865,6 +972,16 @@ export default function Roster() {
/>
)}
{showConfirmModal && confirmEmployee && (
<ConfirmModal
employee={confirmEmployee}
onClose={() => { setShowConfirmModal(false); setConfirmEmployee(null) }}
onSubmit={(data) => confirmMutation.mutate(data)}
loading={confirmMutation.isPending}
error={confirmMutation.error as any}
/>
)}
{showBatchRenewModal && (
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
<div className="space-y-3">
+13 -13
View File
@@ -1148,7 +1148,7 @@ function InitImport() {
<div className="text-amber-600"> {result.skipped} </div>
)}
{result.duplicates > 0 && (
<div className="text-amber-600"> {result.duplicates} </div>
<div className="text-amber-600"> {result.duplicates} </div>
)}
{result.errors?.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
@@ -1214,12 +1214,12 @@ function InitImport() {
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div className="mt-2 text-gray-500"> Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div className="mt-2 text-gray-500"> Sheet </div>
</div>
</Card>
</div>
@@ -1377,12 +1377,12 @@ function MonthlyImport() {
<Card>
<div className="text-xs text-gray-500 space-y-1">
<div className="font-medium text-gray-700 mb-1">Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b>//</div>
<div>- <b></b>//</div>
<div className="mt-2 text-gray-500"> Sheet </div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b></div>
<div>- <b></b>//</div>
<div>- <b></b>//</div>
<div className="mt-2 text-gray-500"> Sheet </div>
</div>
</Card>
</div>
+19 -2
View File
@@ -450,7 +450,12 @@ export default function SpecialStatus() {
{/* 员工选择 */}
<div>
<Label></Label>
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })} disabled={!!editing}>
<Select value={form.employeeId} onChange={(e) => {
const emp = employees.find(em => em.id === e.target.value)
// 选择员工后,若当前类型为三期但员工为男性,自动切换为其他类型
const newType = emp?.gender === '男' && form.type === 'PREGNANCY' ? 'WORK_INJURY' : form.type
setForm({ ...form, employeeId: e.target.value, type: newType })
}} disabled={!!editing}>
<option value=""></option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
@@ -463,11 +468,23 @@ export default function SpecialStatus() {
<div>
<Label></Label>
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="PREGNANCY"></option>
{/* 三期仅适用于女性员工,男职工不显示该选项 */}
{(() => {
const selectedEmp = employees.find(em => em.id === form.employeeId)
const isMale = selectedEmp?.gender === '男'
return isMale ? null : <option value="PREGNANCY"></option>
})()}
<option value="WORK_INJURY"></option>
<option value="MEDICAL_PERIOD"></option>
<option value="OTHER"></option>
</Select>
{(() => {
const selectedEmp = employees.find(em => em.id === form.employeeId)
if (selectedEmp?.gender === '男') {
return <p className="text-xs text-gray-400 mt-1"></p>
}
return null
})()}
</div>
<div>
<Label></Label>
+1 -1
View File
@@ -25,7 +25,7 @@ const CATEGORY_LABELS: Record<string, string> = {
const VARIABLE_LABELS: Record<string, string> = {
companyName: '公司名称',
employeeName: '员工姓名',
idCard: '身份证号',
idCard: '证件号码',
address: '地址',
startDate: '开始日期',
position: '岗位',
+252 -26
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
@@ -162,12 +163,26 @@ interface EmployeeProfile {
export default function Termination() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [searchParams] = useSearchParams()
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
const [draftId, setDraftId] = useState<string | null>(null)
const [step, setStep] = useState(0)
const [reason, setReason] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [terminationDate, setTerminationDate] = useState('')
// 从 URL 参数预填员工和解聘类型(从花名册操作栏跳转)
useEffect(() => {
const urlEmployeeId = searchParams.get('employeeId')
const urlReason = searchParams.get('reason')
if (urlEmployeeId) {
setEmployeeId(urlEmployeeId)
setView('wizard')
}
if (urlReason) {
setReason(urlReason)
}
}, [searchParams])
const [socialInsEndMonth, setSocialInsEndMonth] = useState('')
const [housingFundEndMonth, setHousingFundEndMonth] = useState('')
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
@@ -181,6 +196,18 @@ export default function Termination() {
const [editCompValue, setEditCompValue] = useState<number>(0)
const [editCompReason, setEditCompReason] = useState('')
const [approvalComment, setApprovalComment] = useState('')
/** 违法解除且实际补偿金低于法定2N时需确认风险 */
const [illegalRiskAcknowledged, setIllegalRiskAcknowledged] = useState(false)
/** 补偿月数模式:N/N+1/2N/自定义 */
const [compMonthMode, setCompMonthMode] = useState<'N' | 'N+1' | '2N' | 'CUSTOM'>('N')
const [customCompMonths, setCustomCompMonths] = useState<number>(0)
const [customCompReason, setCustomCompReason] = useState('')
/** 计算基数来源:近12月平均/合同工资/自定义 */
const [wageBaseType, setWageBaseType] = useState<'AVG_12' | 'CONTRACT' | 'CUSTOM'>('AVG_12')
const [customWageBase, setCustomWageBase] = useState<number>(0)
const [customWageReason, setCustomWageReason] = useState('')
/** 剩余年假天数及折算金额 */
const [annualLeaveDays, setAnnualLeaveDays] = useState<number>(0)
const [savedItems, setSavedItems] = useState<Array<{
id: string
employeeId: string
@@ -479,6 +506,28 @@ export default function Termination() {
capped = true
}
// 支持自定义计算基数(近12月平均/合同工资/自定义)
let effectiveWageBase = cappedWage
if (wageBaseType === 'CONTRACT') {
effectiveWageBase = selectedEmployee.latestContract?.monthlySalary || wage
} else if (wageBaseType === 'CUSTOM' && customWageBase > 0) {
effectiveWageBase = customWageBase
// 自定义基数时提示三倍社平封顶但不强制
if (socialAvgWage > 0 && customWageBase > socialAvgWage * 3) {
capped = true // 仅标记提示
}
}
// 支持自定义补偿月数(N/N+1/2N/其他)
let effectiveMonths = cappedMonths
if (compMonthMode === 'N+1') {
effectiveMonths = compMonths + 1
} else if (compMonthMode === '2N') {
effectiveMonths = compMonths * 2
} else if (compMonthMode === 'CUSTOM' && customCompMonths > 0) {
effectiveMonths = customCompMonths
}
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
NEGOTIATED: { multiplier: 1, notice: false },
FAULT: { multiplier: 0, notice: false },
@@ -488,9 +537,9 @@ export default function Termination() {
ILLEGAL: { multiplier: 2, notice: false },
}
const r = reasonMap[reason] || { multiplier: 1, notice: false }
const basePay = cappedWage * cappedMonths
const basePay = effectiveWageBase * effectiveMonths
const severancePay = basePay * r.multiplier
const noticePay = r.notice ? cappedWage : 0
const noticePay = r.notice ? effectiveWageBase : 0
const totalSeverance = severancePay + noticePay
// 双倍工资计算(未签合同)
@@ -525,35 +574,45 @@ export default function Termination() {
isIllegal: r.multiplier === 2,
grandTotal: totalSeverance + doublePay,
}
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
}, [selectedEmployee, terminationDate, socialAvgWage, reason, compMonthMode, customCompMonths, wageBaseType, customWageBase])
// 计算调整后的实际补偿金(系统预估 + 手动调整差额
// 年假折算金额:日工资 × 剩余年假天数 × 3(未休年假按300%支付
const annualLeavePay = useMemo(() => {
if (!costResult || annualLeaveDays <= 0) return 0
const dailyWage = (costResult.cappedWage || 0) / 21.75
return Math.round(dailyWage * annualLeaveDays * 3 * 100) / 100
}, [costResult, annualLeaveDays])
// 计算调整后的实际补偿金(系统预估 + 手动调整差额 + 年假折算)
const adjustedTotal = useMemo(() => {
if (!costResult) return 0
let total = costResult.grandTotal
compAdjustments.forEach(adj => {
total += (adj.to - adj.from)
})
total += annualLeavePay
return total
}, [costResult, compAdjustments])
}, [costResult, compAdjustments, annualLeavePay])
const canProceed = () => {
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
// step 2: 合规检查 — required 项必须勾选
// step 2: 合规检查 — 改为软阻断,未勾选 required 项可下一步但会生成待办
if (step === 2) {
if (!checklistItems) return true
const requiredItems = checklistItems.filter(item => item.suggestionType === 'required')
if (requiredItems.length === 0) return true
return requiredItems.every(item => checklist[item.key] === true)
// 法定禁止情形(孕期/工伤/医疗期)仍硬阻断
if (riskAssessment?.warnings.length && !acknowledgeRisk) return false
return true
}
// step 3: 费用结算 — 有补偿金时需确认
if (step === 3) return true
// step 4: 工作交接 — 关键项需完成
// step 3: 费用结算 — 违法解除且实际补偿金低于法定2N时需确认风险
if (step === 3) {
if (reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal) {
return illegalRiskAcknowledged
}
return true
}
// step 4: 工作交接 — 改为软阻断,未完成可下一步但会生成待办
if (step === 4) {
const keyItems = handoverItems.filter(item => item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke')
if (keyItems.length === 0) return true
return keyItems.every(item => item.done)
return true
}
return false
}
@@ -568,6 +627,13 @@ export default function Termination() {
total: adjustedTotal,
systemTotal: costResult.grandTotal,
adjustments: compAdjustments,
riskAcknowledged: reason === 'ILLEGAL' && adjustedTotal < costResult.grandTotal ? {
acknowledged: illegalRiskAcknowledged,
legalAmount: costResult.grandTotal,
actualAmount: adjustedTotal,
difference: costResult.grandTotal - adjustedTotal,
timestamp: new Date().toISOString(),
} : null,
} : null
saveDraftMutation.mutate({
@@ -583,18 +649,53 @@ export default function Termination() {
compensationBreakdown: breakdown,
handoverItems,
checklistOverrides,
compMonthMode,
customCompMonths: compMonthMode === 'CUSTOM' ? customCompMonths : undefined,
customCompReason: compMonthMode === 'CUSTOM' ? customCompReason : undefined,
wageBaseType,
customWageBase: wageBaseType === 'CUSTOM' ? customWageBase : undefined,
customWageReason: wageBaseType === 'CUSTOM' ? customWageReason : undefined,
annualLeaveDays: annualLeaveDays > 0 ? annualLeaveDays : undefined,
annualLeavePay: annualLeavePay > 0 ? annualLeavePay : undefined,
socialAvgWage: socialAvgWage > 0 ? socialAvgWage : undefined,
remark: '',
})
}
/** 编辑已有草稿 */
const handleEditDraft = (item: any) => {
/** 编辑已有草稿(拉取完整详情后恢复所有字段) */
const handleEditDraft = async (item: any) => {
setDraftId(item.id)
setEmployeeId(item.employeeId)
setReason(item.reason)
setTerminationDate(item.terminationDate)
setStep(item.currentStep || 0)
setView('wizard')
// 恢复所有已保存的字段
if (item.socialInsEndMonth) setSocialInsEndMonth(item.socialInsEndMonth)
if (item.housingFundEndMonth) setHousingFundEndMonth(item.housingFundEndMonth)
if (item.checklist) setChecklist(item.checklist)
if (item.compensationBreakdown) {
setCompBreakdown(item.compensationBreakdown)
if (item.compensationBreakdown.adjustments) {
setCompAdjustments(item.compensationBreakdown.adjustments)
}
// 恢复违法解除风险确认状态
if (item.compensationBreakdown.riskAcknowledged?.acknowledged) {
setIllegalRiskAcknowledged(true)
}
}
if (item.handoverItems) setHandoverItems(item.handoverItems)
if (item.checklistOverrides) setChecklistOverrides(item.checklistOverrides)
// 恢复补偿月数和计算基数设置
if (item.compMonthMode) setCompMonthMode(item.compMonthMode)
if (item.customCompMonths) setCustomCompMonths(item.customCompMonths)
if (item.customCompReason) setCustomCompReason(item.customCompReason)
if (item.wageBaseType) setWageBaseType(item.wageBaseType)
if (item.customWageBase) setCustomWageBase(item.customWageBase)
if (item.customWageReason) setCustomWageReason(item.customWageReason)
if (item.annualLeaveDays) setAnnualLeaveDays(item.annualLeaveDays)
// 恢复社平工资
if (item.socialAvgWage) setSocialAvgWage(item.socialAvgWage)
}
/** 查看详情 */
@@ -675,6 +776,14 @@ export default function Termination() {
setChecklistOverrides({})
setEditingCompField(null)
setApprovalComment('')
setIllegalRiskAcknowledged(false)
setCompMonthMode('N')
setCustomCompMonths(0)
setCustomCompReason('')
setWageBaseType('AVG_12')
setCustomWageBase(0)
setCustomWageReason('')
setAnnualLeaveDays(0)
}
const handleReset = resetWizard
@@ -1079,7 +1188,7 @@ body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align:
</style></head>
<body>
<div class="title">/</div>
<div class="body"> ${draftDetail.employeeName}${draftDetail.idCardNumber || '—'} ${draftDetail.department || '—'} ${draftDetail.terminationDate} ${reasonLabel} /</div>
<div class="body"> ${draftDetail.employeeName}${draftDetail.idCardNumber || '—'} ${draftDetail.department || '—'} ${draftDetail.terminationDate} ${reasonLabel} /</div>
<div class="body">¥${fmt(draftDetail.compensation)}${draftDetail.socialInsEndMonth || '—'}${draftDetail.housingFundEndMonth || '—'}</div>
<div class="body"></div>
<div class="sign"><br/>${new Date().toISOString().slice(0, 10)}</div>
@@ -1275,7 +1384,26 @@ ${items}
})()}
<div>
<Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
<Input type="date" value={terminationDate} onChange={(e) => {
const date = e.target.value
setTerminationDate(date)
// 自动推导社保/公积金截止月:15号前(含)截止上月,15号后截止当月
if (date) {
const d = new Date(date)
const day = d.getDate()
const monthStr = date.slice(0, 7)
if (day <= 15) {
// 上月
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
const prevMonth = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
setSocialInsEndMonth(prevMonth)
setHousingFundEndMonth(prevMonth)
} else {
setSocialInsEndMonth(monthStr)
setHousingFundEndMonth(monthStr)
}
}
}} />
</div>
{/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */}
{step === 1 && costResult && (
@@ -1428,6 +1556,57 @@ ${items}
)}
</div>
{/* 补偿月数和计算基数选择 */}
{!costResult.noComp && (
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
<div className="text-xs font-medium text-gray-700"></div>
{/* 补偿月数模式 */}
<div>
<Label></Label>
<div className="flex gap-2 flex-wrap">
{(['N', 'N+1', '2N', 'CUSTOM'] as const).map(mode => (
<button
key={mode}
type="button"
onClick={() => setCompMonthMode(mode)}
className={`px-3 py-1 rounded text-xs border ${compMonthMode === mode ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
>
{mode === 'N' ? 'N(法定)' : mode === 'N+1' ? 'N+1(含代通知)' : mode === '2N' ? '2N(违法解除)' : '自定义'}
</button>
))}
</div>
{compMonthMode === 'CUSTOM' && (
<div className="mt-2 space-y-1">
<Input type="number" value={customCompMonths || ''} onChange={(e) => setCustomCompMonths(Number(e.target.value) || 0)} placeholder="自定义补偿月数" />
<Input value={customCompReason} onChange={(e) => setCustomCompReason(e.target.value)} placeholder="自定义原因(必填)" />
</div>
)}
</div>
{/* 计算基数来源 */}
<div>
<Label></Label>
<div className="flex gap-2 flex-wrap">
{(['AVG_12', 'CONTRACT', 'CUSTOM'] as const).map(bt => (
<button
key={bt}
type="button"
onClick={() => setWageBaseType(bt)}
className={`px-3 py-1 rounded text-xs border ${wageBaseType === bt ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
>
{bt === 'AVG_12' ? '近12月平均工资' : bt === 'CONTRACT' ? '合同约定工资' : '自定义基数'}
</button>
))}
</div>
{wageBaseType === 'CUSTOM' && (
<div className="mt-2 space-y-1">
<Input type="number" value={customWageBase || ''} onChange={(e) => setCustomWageBase(Number(e.target.value) || 0)} placeholder="自定义月工资基数" />
<Input value={customWageReason} onChange={(e) => setCustomWageReason(e.target.value)} placeholder="自定义原因(必填)" />
</div>
)}
</div>
</div>
)}
{/* 经济补偿金 / 赔偿金 */}
{costResult.noComp ? (
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
@@ -1496,8 +1675,55 @@ ${items}
{/* 合计 */}
<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-danger">¥{fmt(costResult.grandTotal)}</span>
<span className="font-medium"></span>
<span className="text-lg font-bold text-gray-700">¥{fmt(costResult.grandTotal)}</span>
</div>
{compAdjustments.length > 0 && (
<div className="flex items-center justify-between mt-1">
<span className="font-medium text-primary"></span>
<span className="text-lg font-bold text-danger">¥{fmt(adjustedTotal)}</span>
</div>
)}
</div>
{/* 违法解除风险提醒:实际补偿金低于法定2N时需确认 */}
{reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal && (
<div className="border border-danger rounded-md p-3 space-y-2 bg-red-50">
<div className="text-xs font-medium text-danger flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4" />
</div>
<p className="text-xs text-danger">
287 ¥{fmt(adjustedTotal)} ¥{fmt(costResult.grandTotal)} ¥{fmt(costResult.grandTotal - adjustedTotal)}
</p>
<p className="text-xs text-danger"></p>
<label className="flex items-center gap-2 text-xs text-danger cursor-pointer">
<input
type="checkbox"
checked={illegalRiskAcknowledged}
onChange={(e) => setIllegalRiskAcknowledged(e.target.checked)}
/>
<span></span>
</label>
</div>
)}
{/* 剩余年假折算 */}
<div className="border rounded-md p-3 space-y-2 bg-green-50/30">
<div className="text-xs font-medium flex items-center gap-1.5">
<Calculator className="w-3.5 h-3.5" />
</div>
<div className="text-xs text-gray-500">300%5</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<Label></Label>
<Input type="number" min="0" step="0.5" value={annualLeaveDays || ''} onChange={(e) => setAnnualLeaveDays(Number(e.target.value) || 0)} placeholder="0" />
</div>
{annualLeaveDays > 0 && costResult && (
<div className="text-xs text-gray-600">
<div>¥{fmt((costResult.cappedWage || 0) / 21.75)}/</div>
<div className="font-medium text-danger">¥{fmt(annualLeavePay)}</div>
</div>
)}
</div>
</div>
@@ -1884,11 +2110,11 @@ ${items}
</Button>
{step < 4 ? (
<div className="flex items-center gap-2">
{!canProceed() && step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
<span className="text-xs text-danger"></span>
{step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
<span className="text-xs text-amber-600"></span>
)}
{!canProceed() && step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
<span className="text-xs text-danger"></span>
{step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
<span className="text-xs text-amber-600"></span>
)}
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" />
+52 -5
View File
@@ -57,7 +57,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
{ key: 'monthlySalary', label: '月薪', type: 'number' },
{ key: 'phone', label: '手机号', type: 'text', required: true },
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
{ key: 'idCardNumber', label: '证件号码', type: 'text', required: true },
{ key: 'gender', label: '性别', type: 'select', options: ['男', '女'] },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
@@ -92,7 +92,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
],
RENEW: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
{ key: 'oldContractId', label: '原合同', type: 'contract-select' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date', required: true },
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
{ key: 'newSalary', label: '新薪资', type: 'number' },
@@ -106,7 +106,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'idCardNumber', label: '证件号码', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
{ key: 'purpose', label: '用途', type: 'text' },
@@ -129,7 +129,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'idCardNumber', label: '证件号码', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'leaveDate', label: '离职日期', type: 'date', required: true },
@@ -137,7 +137,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
FLEXIBLE: [
{ key: 'name', label: '姓名', type: 'text', required: true },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
{ key: 'idCardNumber', label: '证件号码', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date', required: true },
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
@@ -151,6 +151,28 @@ const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat(
return acc
}, {} as Record<string, string>)
/** 日期字段对配置:各流程类型的开始/结束日期字段对 */
const DATE_RANGE_FIELDS: Record<string, Array<{ start: string; end: string; startLabel: string; endLabel: string }>> = {
HIRE: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
CUSTOM_CONTRACT: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
RENEW: [{ start: 'newStartDate', end: 'newEndDate', startLabel: '新合同开始日期', endLabel: '新合同结束日期' }],
FLEXIBLE: [{ start: 'agreementStartDate', end: 'agreementEndDate', startLabel: '协议开始日期', endLabel: '协议结束日期' }],
}
/** 校验日期前后关系:结束日期不能早于开始日期 */
function validateDateRange(type: string, data: Record<string, any>): string | null {
const pairs = DATE_RANGE_FIELDS[type]
if (!pairs) return null
for (const pair of pairs) {
const start = data[pair.start]
const end = data[pair.end]
if (start && end && new Date(end) < new Date(start)) {
return `${pair.endLabel}不能早于${pair.startLabel}`
}
}
return null
}
export default function WorkProcess() {
const navigate = useNavigate()
const queryClient = useQueryClient()
@@ -277,6 +299,12 @@ export default function WorkProcess() {
toast.error(`请填写必填项:${missingFields.map(f => f.label).join('、')}`)
return
}
// 校验日期前后关系
const dateError = validateDateRange(selectedType, formData)
if (dateError) {
toast.error(dateError)
return
}
createMutation.mutate({
type: selectedType,
title: PROCESS_TYPES[selectedType].label,
@@ -291,6 +319,12 @@ export default function WorkProcess() {
toast.error('请选择流程类型')
return
}
// 校验日期前后关系
const dateError = validateDateRange(selectedType, formData)
if (dateError) {
toast.error(dateError)
return
}
createMutation.mutate(
{ type: selectedType, title: PROCESS_TYPES[selectedType].label, employeeId: formData.employeeId || undefined, formData, status: 'DRAFT' },
{
@@ -325,6 +359,19 @@ export default function WorkProcess() {
department: emp.department || prev.department,
phone: emp.phone || prev.phone,
}))
// RENEW 类型:自动推导新合同开始日期 = 原合同结束日期 + 1天
if (selectedType === 'RENEW' && emp.contracts?.length > 0) {
const latestContract = emp.contracts[0]
if (latestContract?.endDate) {
const endDate = new Date(latestContract.endDate)
endDate.setDate(endDate.getDate() + 1)
setFormData(prev => ({
...prev,
oldContractId: latestContract.id,
newStartDate: endDate.toISOString().slice(0, 10),
}))
}
}
}).catch(() => {})
}
}
+2 -2
View File
@@ -131,8 +131,8 @@ export default function Onboarding() {
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="请输入手机号" maxLength={11} />
</div>
<div>
<Label> *</Label>
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入身份证号" maxLength={18} />
<Label> *</Label>
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入证件号码" maxLength={18} />
</div>
<div>
<Label></Label>
+34 -6
View File
@@ -3,6 +3,7 @@ import { useState, useRef } from "react"
import { toast } from "sonner"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi, socialInsuranceApi } from '../../lib/api-services'
import { useConfirm } from '../../hooks/useConfirm'
import { copyToClipboard } from '../../lib/clipboard'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
@@ -12,6 +13,7 @@ import { fmt } from "./shared"
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [editing, setEditing] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
@@ -109,11 +111,36 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
enabled: !editing && !!profile.socialInsBase && !!profile.city,
})
const handleSave = () => {
const handleSave = async () => {
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
toast.error('参保城市变更必须填写变更原因')
return
}
// 入职日期变更联动状态确认
const originalHireDate = profile.hireDate?.toString().slice(0, 10) || ''
let statusUpdate: string | undefined
if (form.hireDate && form.hireDate !== originalHireDate) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const newHireDate = new Date(form.hireDate)
const currentStatus = profile.status || 'ACTIVE'
let newStatus = currentStatus
if (newHireDate > today) {
newStatus = 'PENDING_ONBOARD'
} else {
newStatus = 'ACTIVE'
}
if (newStatus !== currentStatus) {
const statusLabel = newStatus === 'PENDING_ONBOARD' ? '待入职' : '在职'
const currentLabel = currentStatus === 'PENDING_ONBOARD' ? '待入职' : currentStatus === 'ACTIVE' ? '在职' : currentStatus
const confirmed = await confirm({
title: '入职日期变更确认',
message: `入职日期从 ${originalHireDate} 变更为 ${form.hireDate},员工状态将从"${currentLabel}"变更为"${statusLabel}",是否继续?`,
})
if (!confirmed) return
statusUpdate = newStatus
}
}
const data: any = {
department: form.department,
gender: form.gender,
@@ -137,6 +164,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
position: form.position || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
if (statusUpdate) data.status = statusUpdate
updateMutation.mutate(data)
}
@@ -147,7 +175,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
...(profile.gender === '女'
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
: []),
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '证件号码', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '学历', value: profile.education || '未填写' },
{ label: '职务/岗位', value: profile.position || '未填写' },
@@ -221,13 +249,13 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2 flex items-center gap-1">
{f.value}
{f.label === '身份证号' && profile.idCardNumber && (
{f.label === '证件号码' && profile.idCardNumber && (
<button
type="button"
className="text-gray-400 hover:text-primary transition-colors shrink-0"
title="复制身份证号"
title="复制证件号码"
onClick={() => {
copyToClipboard(profile.idCardNumber, '已复制身份证号')
copyToClipboard(profile.idCardNumber, '已复制证件号码')
}}
>
<Copy className="w-3 h-3" />
@@ -317,7 +345,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
) : (
<div className="grid md:grid-cols-2 gap-3">
<div><Label></Label><Input value={profile.name} disabled /></div>
<div><Label></Label><Input value={profile.idCardNumber || ''} disabled /></div>
<div><Label></Label><Input value={profile.idCardNumber || ''} disabled /></div>
<div><Label></Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
<div><Label></Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男"></option><option value="女"></option></Select></div>
{form.gender === '女' && (
+187 -46
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { toast } from "sonner"
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
@@ -152,69 +153,159 @@ export function ResignModal({ employee, onClose, onSubmit, loading, error }: {
socialInsEndMonth: '',
housingFundEndMonth: '',
})
/** 离职/解聘类型:主动离职走快速流程,其他类型跳转解聘向导 */
const [termType, setTermType] = useState<'RESIGNATION' | 'NEGOTIATED' | 'FAULT' | 'NONFAULT' | 'LAYOFF' | 'EXPIRED' | 'ILLEGAL'>('RESIGNATION')
const terminationMonth = form.terminationDate ? form.terminationDate.slice(0, 7) : ''
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
const handleSubmit = () => {
if (termType === 'RESIGNATION') {
onSubmit({
employeeId: employee.id,
terminationDate: new Date(form.terminationDate).toISOString(),
resignationReason: form.resignationReason,
remark: form.remark || undefined,
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
})
} else {
// 非主动离职:跳转 Termination 向导,通过 URL 参数传递
const params = new URLSearchParams({
employeeId: employee.id,
reason: termType,
})
window.location.href = `/termination?${params.toString()}`
}
}
return (
<Modal open onClose={onClose} title={`办理离职/解聘 - ${employee.name}`}>
<div className="space-y-3">
{/* 解聘类型选择 */}
<div>
<Label>/</Label>
<Select value={termType} onChange={(e) => setTermType(e.target.value as any)}>
<option value="RESIGNATION"></option>
<option value="NEGOTIATED"></option>
<option value="FAULT"></option>
<option value="NONFAULT"></option>
<option value="LAYOFF"></option>
<option value="EXPIRED"></option>
<option value="ILLEGAL"></option>
</Select>
</div>
{termType === 'RESIGNATION' ? (
<>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<Input
type="date"
value={form.terminationDate}
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
/>
</div>
<div>
<Label></Label>
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
{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="补充说明" />
</div>
</>
) : (
<div className="bg-amber-50 text-amber-700 text-xs px-3 py-2 rounded-md space-y-1">
<div className="font-medium"></div>
<p>"{termType === 'NEGOTIATED' ? '协商解除' : termType === 'FAULT' ? '过错解除' : termType === 'NONFAULT' ? '非过错解除' : termType === 'LAYOFF' ? '裁员' : termType === 'EXPIRED' ? '到期不续签' : '违法解除'}"</p>
<p>"进入解聘向导"</p>
</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}>
{termType === 'RESIGNATION' ? (loading ? '提交中...' : '确认离职') : '进入解聘向导'}
</Button>
</div>
</div>
</Modal>
)
}
/** 转正确认弹窗 */
export function ConfirmModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const [form, setForm] = useState({
confirmDate: new Date().toISOString().slice(0, 10),
regularSalary: '',
})
const handleSubmit = () => {
onSubmit({
employeeId: employee.id,
terminationDate: new Date(form.terminationDate).toISOString(),
resignationReason: form.resignationReason,
remark: form.remark || undefined,
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
confirmDate: new Date(form.confirmDate).toISOString(),
regularSalary: form.regularSalary ? Number(form.regularSalary) : undefined,
})
}
return (
<Modal open onClose={onClose} title={`办理离职 - ${employee.name}`}>
<Modal open onClose={onClose} title={`办理转正 - ${employee.name}`}>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<Input
type="date"
value={form.terminationDate}
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
/>
<Label> *</Label>
<Input type="date" value={form.confirmDate} onChange={(e) => setForm({ ...form, confirmDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
{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="补充说明" />
<Label></Label>
<Input type="number" value={form.regularSalary} onChange={(e) => setForm({ ...form, regularSalary: e.target.value })} placeholder="不填则保持原薪资" />
<div className="text-xs text-gray-400 mt-1"></div>
</div>
{error && (
<div className="text-xs text-danger">
@@ -224,7 +315,7 @@ export function ResignModal({ employee, onClose, onSubmit, loading, error }: {
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? '提交中...' : '确认离职'}
{loading ? '提交中...' : '确认转正'}
</Button>
</div>
</div>
@@ -554,8 +645,10 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
}
}
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)+ 查重
// 根据证件号码自动计算性别(第17位:奇数=男,偶数=女)+ 查重 + 年龄合规筛查
const [idCardDuplicate, setIdCardDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
const [ageWarning, setAgeWarning] = useState<{ type: 'BLOCK' | 'WARN'; message: string } | null>(null)
const [phoneDuplicate, setPhoneDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
const handleIdCardChange = (idCard: string) => {
let gender = form.gender
if (idCard.length >= 17) {
@@ -564,10 +657,32 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
}
setForm({ ...form, idCardNumber: idCard, gender })
setIdCardDuplicate(null)
setAgeWarning(null)
if (idCard.length === 18) {
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
setIdCardDuplicate(data)
}).catch(() => {})
// 年龄合规筛查:从证件号码提取出生日期计算年龄
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
if (!isNaN(birthYear) && !isNaN(birthMonth) && !isNaN(birthDay)) {
const today = new Date()
let age = today.getFullYear() - birthYear
const monthDiff = today.getMonth() - (birthMonth - 1)
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDay)) {
age--
}
if (age < 16) {
setAgeWarning({ type: 'BLOCK', message: `该员工年龄 ${age} 岁,未满16周岁,禁止招用童工(《劳动法》第15条)` })
} else if (age < 18) {
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,未满18周岁,属于未成年工,需遵守特殊保护规定(《劳动法》第58条)` })
} else if (gender === '男' && age >= 60) {
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达法定退休年龄(男60岁),建议确认是否按退休处理` })
} else if (gender === '女' && age >= 50) {
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达或接近法定退休年龄(女工人50岁/干部55岁),建议确认是否按退休处理` })
}
}
}
}
@@ -633,6 +748,11 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
}
const handleSubmit = () => {
// 童工阻断:未满16周岁禁止录入
if (ageWarning?.type === 'BLOCK') {
toast.error(ageWarning.message)
return
}
const data: any = {
name: form.name, department: form.department,
position: form.position || undefined,
@@ -684,11 +804,17 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div><Label> *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
<div><Label> *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label> *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
<div><Label> *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
{idCardDuplicate?.exists && (
<div className="col-span-4 px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{idCardDuplicate.employee?.name}{idCardDuplicate.employee?.department}</span>
<span>{idCardDuplicate.employee?.name}{idCardDuplicate.employee?.department}</span>
</div>
)}
{ageWarning && (
<div className={`col-span-4 px-3 py-2 rounded-md text-xs flex items-center gap-2 ${ageWarning.type === 'BLOCK' ? 'bg-red-50 text-danger' : 'bg-amber-50 text-amber-700'}`}>
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{ageWarning.message}</span>
</div>
)}
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
@@ -699,8 +825,23 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
<div><Label> *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
<div><Label></Label><Input value={form.phone} onChange={(e) => {
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
setForm({ ...form, phone })
setPhoneDuplicate(null)
if (phone.length === 11) {
employeeApi.checkPhone(phone).then((data: { exists: boolean; employee?: any }) => {
setPhoneDuplicate(data)
}).catch(() => {})
}
}} placeholder="选填" maxLength={11} /></div>
</div>
{phoneDuplicate?.exists && (
<div className="px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{phoneDuplicate.employee?.name}{phoneDuplicate.employee?.department}</span>
</div>
)}
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={async (e) => {
const city = e.target.value
@@ -199,7 +199,7 @@ export default function CommercialInsuranceTab() {
<tr className="border-b text-xs 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-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
@@ -0,0 +1,221 @@
/**
* -
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Plus, MessageSquare, Ticket as TicketIcon, Users, Building } from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
import api from '../../lib/api'
export default function SupportDashboard() {
const [tab, setTab] = useState<'tickets' | 'tenants' | 'chat'>('tickets')
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({ title: '', content: '', category: '', priority: 'NORMAL' })
const [selectedTicket, setSelectedTicket] = useState<any>(null)
const [reply, setReply] = useState('')
const queryClient = useQueryClient()
const { data: tickets = [], isLoading } = useQuery({
queryKey: ['support-tickets'],
queryFn: () => api.get('/support/tickets').then(r => r.data.data),
})
const { data: tenants = [] } = useQuery({
queryKey: ['support-tenants'],
queryFn: () => api.get('/support/tenants').then(r => r.data.data),
enabled: tab === 'tenants',
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post('/support/tickets', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['support-tickets'] })
setShowCreate(false)
setForm({ title: '', content: '', category: '', priority: 'NORMAL' })
toast.success('工单已创建')
},
})
const replyMutation = useMutation({
mutationFn: (data: { id: string; content: string }) => api.post(`/support/tickets/${data.id}/messages`, { content: data.content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['support-tickets'] })
setReply('')
},
})
const closeMutation = useMutation({
mutationFn: (id: string) => api.post(`/support/tickets/${id}/close`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['support-tickets'] })
setSelectedTicket(null)
toast.success('工单已关闭')
},
})
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1">穿</p>
</div>
<div className="flex gap-2 border-b">
<button onClick={() => setTab('tickets')} className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'tickets' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}>
<TicketIcon className="w-4 h-4 inline mr-1" />
</button>
<button onClick={() => setTab('tenants')} className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'tenants' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}>
<Building className="w-4 h-4 inline mr-1" />
</button>
<button onClick={() => setTab('chat')} className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}>
<MessageSquare className="w-4 h-4 inline mr-1" />
</button>
</div>
{tab === 'tickets' && (
<Card className="p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium"></span>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">...</div>
) : tickets.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-2">
{tickets.map((t: any) => (
<div
key={t.id}
className="border rounded-md p-3 hover:bg-gray-50 cursor-pointer"
onClick={() => setSelectedTicket(t)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t.title}</span>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded ${t.status === 'OPEN' ? 'bg-blue-50 text-blue-600' : t.status === 'IN_PROGRESS' ? 'bg-amber-50 text-amber-600' : t.status === 'RESOLVED' ? 'bg-green-50 text-green-600' : 'bg-gray-100 text-gray-500'}`}>
{t.status === 'OPEN' ? '待处理' : t.status === 'IN_PROGRESS' ? '处理中' : t.status === 'RESOLVED' ? '已解决' : '已关闭'}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${t.priority === 'URGENT' ? 'bg-red-50 text-red-600' : t.priority === 'HIGH' ? 'bg-orange-50 text-orange-600' : 'bg-gray-100 text-gray-500'}`}>
{t.priority === 'URGENT' ? '紧急' : t.priority === 'HIGH' ? '高' : t.priority === 'NORMAL' ? '普通' : '低'}
</span>
</div>
</div>
<div className="text-xs text-gray-500 mt-1">{t.org?.name} · {t._count?.messages || 0} · {new Date(t.createdAt).toLocaleString()}</div>
</div>
))}
</div>
)}
</Card>
)}
{tab === 'tenants' && (
<Card className="p-4">
<div className="text-sm font-medium mb-3"></div>
{tenants.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-2">
{tenants.map((t: any) => (
<div key={t.id} className="border rounded-md p-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">{t.name}</span>
<span className="text-xs text-gray-400 ml-2">{t.plan}</span>
</div>
<div className="text-xs text-gray-500">
{t._count?.employees || 0} · {t._count?.users || 0}
</div>
</div>
<div className="text-xs text-gray-400 mt-1">
{t.contactName || '未设置'} · {t.contactPhone || '无'}
</div>
</div>
))}
</div>
)}
</Card>
)}
{tab === 'chat' && (
<Card className="p-4">
<div className="text-center py-8 text-gray-400 text-sm"></div>
</Card>
)}
{/* 创建工单弹窗 */}
{showCreate && (
<Modal open onClose={() => setShowCreate(false)} title="提交工单">
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="简要描述问题" />
</div>
<div>
<Label> *</Label>
<textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} placeholder="详细描述问题" rows={4} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
<option value=""></option>
<option value="BUG">Bug</option>
<option value="CONSULT"></option>
<option value="DATA"></option>
<option value="OTHER"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}>
<option value="LOW"></option>
<option value="NORMAL"></option>
<option value="HIGH"></option>
<option value="URGENT"></option>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowCreate(false)}></Button>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.title || !form.content}></Button>
</div>
</div>
</Modal>
)}
{/* 工单详情弹窗 */}
{selectedTicket && (
<Modal open onClose={() => setSelectedTicket(null)} title={selectedTicket.title} size="lg">
<div className="space-y-3">
<div className="text-xs text-gray-500">{selectedTicket.org?.name} · {new Date(selectedTicket.createdAt).toLocaleString()}</div>
<div className="text-sm text-gray-700 bg-gray-50 p-3 rounded-md">{selectedTicket.content}</div>
{selectedTicket.messages?.map((m: any, i: number) => (
<div key={i} className={`text-sm p-2 rounded-md ${m.senderRole === 'SUPPORT' ? 'bg-blue-50 text-right' : 'bg-gray-50'}`}>
<div className="text-xs text-gray-400 mb-1">{m.senderRole === 'SUPPORT' ? '客服' : '客户'} · {new Date(m.createdAt).toLocaleString()}</div>
{m.content}
</div>
))}
{selectedTicket.status !== 'CLOSED' && (
<>
<div className="flex gap-2">
<Input value={reply} onChange={(e) => setReply(e.target.value)} placeholder="输入回复内容" onKeyDown={(e) => { if (e.key === 'Enter' && reply) replyMutation.mutate({ id: selectedTicket.id, content: reply }) }} />
<Button onClick={() => reply && replyMutation.mutate({ id: selectedTicket.id, content: reply })}></Button>
</div>
<div className="flex justify-end">
<Button variant="secondary" onClick={() => closeMutation.mutate(selectedTicket.id)}></Button>
</div>
</>
)}
</div>
</Modal>
)}
</div>
)
}