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:
@@ -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,调整,,,单立户客服端,,,
|
||||
|
+384
@@ -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 完成,不可先行移除。
|
||||
@@ -0,0 +1,751 @@
|
||||
# 20260815 优化执行计划
|
||||
|
||||
> 基于 `20260815-优化.md`(31 条优化项)制定的可执行任务清单。
|
||||
> 覆盖范围:P0(6 条)+ P1(14 条)+ P2(9 条)+ 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-10(10 日 < 15 日)→ 社保截止 2026-07 → 公积金截止 2026-07
|
||||
- 手工验证:离职日期 2026-08-20(20 日 > 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)→ 折算 ¥6900(5×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 枚举新增 SUPPORT,migration + 回滚方案
|
||||
- **测试要求**:
|
||||
- 手工验证:客服登录 → 查看工单列表 → 回复工单 → 选择租户 → 穿透查看花名册 → 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 变更,需先备份 DB,migration 需含回滚方案。
|
||||
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(男职工三期)可能存在历史脏数据,提供查询列表但不自动修复。
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || '' }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 风险提示
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '系统帮助-常见问题' },
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
|
||||
|
||||
|
||||
@@ -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>`),
|
||||
|
||||
Generated
+4
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: '查看和删除已上传文件' },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,7 +25,7 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
const VARIABLE_LABELS: Record<string, string> = {
|
||||
companyName: '公司名称',
|
||||
employeeName: '员工姓名',
|
||||
idCard: '身份证号',
|
||||
idCard: '证件号码',
|
||||
address: '地址',
|
||||
startDate: '开始日期',
|
||||
position: '岗位',
|
||||
|
||||
@@ -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">
|
||||
违法解除劳动合同应按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)。当前实际补偿金 ¥{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" />
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 === '女' && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user