Compare commits

25 Commits

Author SHA1 Message Date
selfrelease e1b5ae9aab 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>
2026-08-15 12:37:27 +08:00
freedakgmail 8cfdd566af docs: 更新使用帮助近期更新,添加8月11日16项优化说明 2026-08-11 22:02:20 +08:00
freedakgmail 86e5526a83 fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页
- 问题2: 离职证明模板支持自定义+员工端下载
- 问题4(P0): 修复工资填写后数据归零问题
- 问题5: 社保添加员工参保信息列表
- 问题6(P0): 商业保险支持为员工参保
- 问题7(P0): 员工福利支持为员工添加福利
- 问题8: 规章制度支持导入Word文档
- 问题9: 文本模板下载Word增加HTML格式
- 问题10: 模板下载变量替换修复(排除token参数)
- 问题11(P0): 电子签署发起时员工下拉框有选项
- 问题12: 新增绩效记录添加考评人选项
- 问题13: 违纪记录添加处罚执行细节
- 问题14: 特殊员工列表添加查看详情按钮和姓名链接
- 问题15: 员工福利汇总正确显示参保人员
- 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
2026-08-11 22:02:19 +08:00
selfrelease b682178549 fix: 用工办理必填项校验(前后端)+ 20260811优化验证文档 2026-08-11 17:15:51 +08:00
freedakgmail de7f1830a7 docs: 更新使用帮助近期更新为28项优化说明 2026-08-09 12:04:30 +08:00
freedakgmail a2e9ba55c2 feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
2026-08-09 11:59:02 +08:00
freedakgmail c355a7d208 feat: 20260805 系统优化 - 身份证复制fallback/薪税日期筛选/社保版本修复/证据链导出/违纪证明/医疗期政策/绩效类型评级/合同作废/帮助更新 2026-08-05 20:26:16 +08:00
freedakgmail a5901d648e fix: 近期更新补充全部已完成项(离职校验/补偿金调整/退休提醒/休假审批入口/员工端辞职申请) 2026-08-05 08:59:16 +08:00
freedakgmail 9a960d8274 fix: 近期更新补充昨天完成的6项改动(身份证搜索/企业名称/表单中文/工作台Tab/电子签场景/通知设置) 2026-08-05 08:51:58 +08:00
freedakgmail 05b171b4e9 feat: 使用帮助新增'近期更新'分类,列出培训/绩效/违纪列表页、员工端签收、电子签扩展等更新说明 2026-08-05 08:48:27 +08:00
freedakgmail ddc51b33fd feat: 电子签署设置新增培训/绩效/违纪3个开关,改为两列卡片布局 2026-08-05 08:44:11 +08:00
freedakgmail 3afb383172 feat: 培训/绩效/违纪员工端签收+管理端签字状态只读化
- 后端: portal新增培训/绩效/违纪列表查看+签收接口,签收时创建证据链
- 管理端: 弹窗去掉HR手动签字勾选,改为只读显示签字状态
- 绩效列表新增签字状态列
- 员工端: 新增MyRecords页面,支持培训签收/拒绝、绩效签字、违纪签字
- 员工端导航新增'我的记录'入口
- EvidenceCategory新增TRAINING/PERFORMANCE类型
2026-08-05 08:39:04 +08:00
freedakgmail 15dde27701 fix: 培训/绩效/违纪列表页CRUD改用api实例(修复401鉴权失败) 2026-08-05 08:28:51 +08:00
freedakgmail b239465e78 feat: 培训记录/绩效考核/违纪记录独立列表页+菜单入口
- 后端: 新增3个组织级列表接口 (training/performance/disciplinary /list)
- 前端: 新增3个列表页面,支持搜索、分页、新增/编辑/删除弹窗
- 侧边栏: 团队分组新增培训记录、绩效考核、违纪记录入口
- 路由+面包屑注册
- 社公商保改名为社保公积金
2026-08-05 08:21:53 +08:00
freedakgmail 539e384ee1 fix: 发薪日缺省5号+通知设置去掉重复发薪日字段 2026-08-05 08:08:18 +08:00
freedakgmail 6ec939dc7f feat: 发薪日期多选+提前N天提醒+电子签设置区域修复
- Schema: 去掉 payrollFrequency,新增 payrollDays (JSON数组) + payrollReminderDays (Int)
- 设置页: 发薪日期改为1-28号多选按钮,新增提前提醒天数设置
- 设置页: 恢复电子签署设置区域(3个开关:规章制度/工资条/入职文件)
- 工作日历: 发薪日期作为 PAYROLL_DAY 事件显示
- 工作台: 提前N天提醒发薪日期,N可配置
- TaskCenter: 新增发薪提醒分类图标
- seed文件: 更新为 payrollDays 格式
2026-08-05 08:04:39 +08:00
freedakgmail 41b3030442 refactor: 设置页面调整-退休提醒独立tab+显示设置并入企业信息
- 退休提醒从企业信息中拆分为独立tab(RetirementSettings 独立 Card 组件)
- 显示设置从独立tab移入企业信息卡片底部(移除 DisplaySettings 组件)
- tab顺序:企业信息、用户管理、套餐、通知设置、退休提醒、数据导入、数据导出
2026-08-05 07:53:53 +08:00
freedakgmail 9512b555ee feat: 电子签全场景集成+场景筛选+设置开关
- ESignRecord 模型新增 scene 字段(CONTRACT/RESIGNATION/POLICY/PAYSLIP/ONBOARDING)
- Organization 模型新增3个电子签开关:esignPolicyEnabled/esignPayslipEnabled/esignOnboardingEnabled
- 设置页面企业信息新增电子签署设置区域,3个开关各自独立,缺省关闭
- 规章制度签收:开启电子签后,员工阅读确认时自动创建POLICY场景签署记录
- 工资条确认:开启电子签后,员工确认工资条时自动创建PAYSLIP场景签署记录
- 入职文件签署:开启电子签后,HR审批通过入职流程时自动创建ONBOARDING场景签署记录
- 电子签署列表增加场景筛选下拉(全部场景/劳动合同/离职协议/规章制度/工资条/入职文件)
- 管理端和员工端列表均展示场景标签(基于scene字段,替代硬编码判断)
- 合同和离职流程的esign调用已加scene参数
2026-08-05 07:47:00 +08:00
freedakgmail e8cd0f472b feat: 电子签署导航调整+员工手机端电子签署功能
- 管理端导航:电子签署移到首页分组(工作日历下)
- 员工端新增电子签署页面(MyEsign.tsx):
  - 查看自己的签署记录列表,支持待签/已签/已取消状态
  - 查看签署详情,含文件内容、关联合同标识
  - 确认签署操作(框架阶段,对接易签宝后跳转签署页面)
- 员工端导航(PortalNav)增加电子签署入口
- 员工首页(EmployeeHome)增加电子签署快捷入口
- 员工首页待办事项增加待签署文件提醒
- 后端portal路由新增3个接口:GET /esign、GET /esign/:id、POST /esign/:id/sign
2026-08-05 07:39:11 +08:00
freedakgmail b9240ffe9b feat: 电子签署集成到合同管理和离职流程
- 合同管理:选择电子签署时保存后自动创建ESignRecord并关联合同ID
- 离职管理:工作交接清单「离职文件签署」步骤增加「发起电子签署」按钮
- 电子签署页面:列表增加来源标识(劳动合同/离职协议)
- 合同卡片:电子签署合同显示提示而非手动填写编号/链接
2026-08-05 07:34:01 +08:00
freedakgmail cce0c936bd refactor: 侧边栏「特殊状态」改名为「特殊员工」 2026-08-05 07:29:39 +08:00
freedakgmail b576a48ff1 refactor: 调整侧边栏分组顺序,电子签署移至团队分组
- 电子签署从「福利保障」移到「团队」分组(与合同管理、用工办理同属员工生命周期)
- 「时间」分组移到「薪酬」之前
- 导航顺序:首页 → 团队 → 时间 → 薪酬 → 福利保障 → 合规
2026-08-05 07:27:41 +08:00
freedakgmail 13c9192b0a feat: 商业保险新增员工汇总Tab
- 后端新增 /commercial-insurance/employee-summary 接口,按员工维度汇总商险
- 前端商业保险页面新增Tab切换:方案管理 + 员工汇总
- 员工汇总展示每人参保项、年保费合计、保额合计,底部带总计行
2026-08-05 07:25:14 +08:00
freedakgmail c328172e7d feat: 商业保险/员工福利独立页面+易签宝电子签署框架
- 商业保险:从社公商保中拆出为独立页面,侧边栏新增「福利保障」分组
- 员工福利:新建完整模块(方案管理+批量参保+员工汇总),Prisma模型+后端路由+前端页面
- 电子签署:搭建易签宝对接框架(ESignRecord模型+创建/查询/取消/回调接口+前端签署管理页面)
- 侧边栏新增「福利保障」分组:商业保险、员工福利、电子签署
- Prisma schema 新增6个模型:CommercialInsurancePlan/Enrollment, EmployeeBenefitPlan/Enrollment, ESignRecord
2026-08-04 23:08:49 +08:00
freedakgmail 5604d02de9 feat: 完成23项系统优化 - 花名册社保状态列/身份证复制/附件类型扩展, 用工办理姓名检索/直接提交/文书查看/批量证明, 风险中心跳转筛选+批量处理, 日历7/15/35天分组+逾期统计, 考勤单条编辑+按人导出, 工资条查看状态+工资流水导出, 辞职申请附件上传, 交接清单PDF下载, 操作完成下一步引导, 休假审批入口, 人效成本分部门 2026-08-04 22:55:03 +08:00
114 changed files with 14184 additions and 1162 deletions
+32
View File
@@ -0,0 +1,32 @@
序号,类型,页面,功能点,需调整项目,字典表,逻辑,备注
1,调整,,,“劳动合同”,是否应调整为更为宽泛的用工关系,,
2,调整,离职管理,,“离职日期”,,办理离职的离职日期调整后,应影响社保截止年月和公积金截止年月(例如15日之前离职不缴纳当月社保、15日之后离职缴纳当月社保)可配置,
3,调整,离职管理,合规检查,“是否已支付经济补偿金”,,不勾选无法进行下一步,应增加保存后未办事项进入代办和风险提醒中,
4,新增,离职管理,费用结算,“剩余年假天数”,,调用或录入,影响离职日期计算,
5,调整,离职管理,费用结算,“补偿月数”,N、N+1、2N、其他,选择协商解除时可填写修改并重新运算补偿金,
6,调整,离职管理,费用结算,“计算基数”,近12月平均工资、合同工资、其他,可调整,按照选用算法计算,其他需手动填写,
7,调整,离职管理,费用结算,“手动调整补偿金分项”,,调整后合计应付未同步更新,
8,调整,离职管理,工作交接,勾选的内容应为待办事项,前三项为离职必须要执行的任务,,未到离职日期,工作交接未完成,无法进行下一步操作,应调整为可进行下一步,未完成事项进入代办提醒,
9,调整,离职管理,,将离职管理模块中的“新建解聘”功能并入到花名册员工列表的操作栏中的离职功能,,,
10,调整,离职管理,,“解聘驳回”,,解聘驳回后,重新修改了解聘类型、金额等,系统未按照新内容更新,
11,调整,离职管理,,“新增解聘”,,选择了违法解除后(2N),修改降低了经济补偿金金额,合规提醒并未风险提示可继续进行下一步操作,需要增加合规风险提醒,
12,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同开始日期应为上一份合同结束日期+1(例如第一份合同结束日期为2023年5月31日,那么新增合同后合同开始日期应为2026年6月1日),
13,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同结束日期早于合同开始日期,系统未校验判断产生了时间倒置,
14,调整,花名册,添加员工,“身份证号”,,全域修改为“证件号码”,
15,调整,花名册,添加员工,年龄限制,,添加员工时根据证件号码对员工年龄进行合规性筛查,
16,调整,花名册,员工明细编辑,编辑修改“入职日期”,,花名册明细中的状态未变更(例如从今天变更为下月1日入职,状态应变更为待入职),
17,调整,花名册,添加员工,“手机号”,,录入手机号时进行查重,
18,调整,用工办理,员工转正,将用工办理模块中的“员工转正”功能移植到花名册员工列表的操作栏中,替换发薪功能,,,
19,调整,用工办理,员工转正,“员工转正”,,填写了转正薪资,在员工花名册里未体现变更后内容,是否与已签订的用工协议匹配需要验证,如不一致应发起合同变更流程,
20,调整,用工办理,开具证明,将用工办理模块中的“开具证明”功能移植到花名册员工列表的操作栏中,,,
21,调整,用工办理,合同续签,将用工办理模块中的“合同续签”功能移植到花名册员工列表的操作栏中,,,
22,调整,用工办理,合同变更,“合同变更”,,选择员工无法查询到对应的合同,
23,调整,用工办理,合同续签,“合同续签”,,原合同ID无法查询,建议修改为选择该员工已存在的合同,
24,调整,花名册,,勾选多名员工时,增加批量转正功能,,,
25,调整,花名册,,勾选多名员工时,增加批量开具证明功能,,,
26,调整,用工办理,,去掉用工办理模块,,,
27,调整,系统设置,,增加简单的组织架构,用于满足三步以内的审批流转,,,
28,调整,薪税管理,创建发薪批次,补偿金批次,,批次类型为补偿金批次的,经济补偿金发放数据读取错误,
29,调整,,特殊员工,“特殊员工”,,男职工应无法选择三期,
30,BUG,全域,,全域保存草稿后部分录入的数据未保存,,全域保存草稿后部分录入的数据未保存,
31,调整,,,单立户客服端,,,
1 序号 类型 页面 功能点 需调整项目 字典表 逻辑 备注
2 1 调整 “劳动合同” 是否应调整为更为宽泛的用工关系
3 2 调整 离职管理 “离职日期” 办理离职的离职日期调整后,应影响社保截止年月和公积金截止年月(例如15日之前离职不缴纳当月社保、15日之后离职缴纳当月社保)可配置
4 3 调整 离职管理 合规检查 “是否已支付经济补偿金” 不勾选无法进行下一步,应增加保存后未办事项进入代办和风险提醒中
5 4 新增 离职管理 费用结算 “剩余年假天数” 调用或录入,影响离职日期计算
6 5 调整 离职管理 费用结算 “补偿月数” N、N+1、2N、其他 选择协商解除时可填写修改并重新运算补偿金
7 6 调整 离职管理 费用结算 “计算基数” 近12月平均工资、合同工资、其他 可调整,按照选用算法计算,其他需手动填写
8 7 调整 离职管理 费用结算 “手动调整补偿金分项” 调整后合计应付未同步更新
9 8 调整 离职管理 工作交接 勾选的内容应为待办事项,前三项为离职必须要执行的任务 未到离职日期,工作交接未完成,无法进行下一步操作,应调整为可进行下一步,未完成事项进入代办提醒
10 9 调整 离职管理 将离职管理模块中的“新建解聘”功能并入到花名册员工列表的操作栏中的离职功能
11 10 调整 离职管理 “解聘驳回” 解聘驳回后,重新修改了解聘类型、金额等,系统未按照新内容更新
12 11 调整 离职管理 “新增解聘” 选择了违法解除后(2N),修改降低了经济补偿金金额,合规提醒并未风险提示可继续进行下一步操作,需要增加合规风险提醒
13 12 调整 用工办理 合同续签 “新增合同” 续签新增合同,合同开始日期应为上一份合同结束日期+1(例如第一份合同结束日期为2023年5月31日,那么新增合同后合同开始日期应为2026年6月1日)
14 13 调整 用工办理 合同续签 “新增合同” 续签新增合同,合同结束日期早于合同开始日期,系统未校验判断产生了时间倒置
15 14 调整 花名册 添加员工 “身份证号” 全域修改为“证件号码”
16 15 调整 花名册 添加员工 年龄限制 添加员工时根据证件号码对员工年龄进行合规性筛查
17 16 调整 花名册 员工明细编辑 编辑修改“入职日期” 花名册明细中的状态未变更(例如从今天变更为下月1日入职,状态应变更为待入职)
18 17 调整 花名册 添加员工 “手机号” 录入手机号时进行查重
19 18 调整 用工办理 员工转正 将用工办理模块中的“员工转正”功能移植到花名册员工列表的操作栏中,替换发薪功能
20 19 调整 用工办理 员工转正 “员工转正” 填写了转正薪资,在员工花名册里未体现变更后内容,是否与已签订的用工协议匹配需要验证,如不一致应发起合同变更流程
21 20 调整 用工办理 开具证明 将用工办理模块中的“开具证明”功能移植到花名册员工列表的操作栏中
22 21 调整 用工办理 合同续签 将用工办理模块中的“合同续签”功能移植到花名册员工列表的操作栏中
23 22 调整 用工办理 合同变更 “合同变更” 选择员工无法查询到对应的合同
24 23 调整 用工办理 合同续签 “合同续签” 原合同ID无法查询,建议修改为选择该员工已存在的合同
25 24 调整 花名册 勾选多名员工时,增加批量转正功能
26 25 调整 花名册 勾选多名员工时,增加批量开具证明功能
27 26 调整 用工办理 去掉用工办理模块
28 27 调整 系统设置 增加简单的组织架构,用于满足三步以内的审批流转
29 28 调整 薪税管理 创建发薪批次 补偿金批次 批次类型为补偿金批次的,经济补偿金发放数据读取错误
30 29 调整 特殊员工 “特殊员工” 男职工应无法选择三期
31 30 BUG 全域 全域保存草稿后部分录入的数据未保存 全域保存草稿后部分录入的数据未保存
32 31 调整 单立户客服端
+384
View File
@@ -0,0 +1,384 @@
# 20260815 优化清单
> 来源:`20260815-优化.csv`(共 31 条)
> 结合 TurboHR 当期功能梳理,按模块归类,标注根因 / 修复方向 / 优先级 / 涉及文件。
> 优先级:P0 紧急(影响业务正确性/数据丢失)|P1 高(流程阻塞或合规风险)|P2 中(体验/增强)|P3 规划(新模块)
---
## 一、离职管理(问题 2-11
### 问题 2:离职日期调整后未联动社保/公积金截止年月
- **现状**`Termination.tsx:1334-1338` 社保截止、公积金截止为 `<Input type="month">` 手动填写,默认值取 `terminationDate.slice(0,7)`,但修改离职日期后不会重算,也无"15 日前/后"规则。
- **修复方向**
1. 增加"离职日期 → 社保/公积金截止月"自动推导规则:每月 15 日(含)前离职不缴当月、15 日后离职缴纳当月,截止月 = 离职月 - 0 或 -1。
2. 阈值日期(15 日)做成系统设置可配置项(`org_settings.socialInsCutoffDay`,默认 15)。
3. 离职日期变更时自动回填两个截止月,同时保留手动覆盖入口。
- **优先级**P1 高
- **涉及文件**`frontend/src/pages/Termination.tsx``backend/src/services/termination.service.ts``backend/prisma/schema.prisma`org_settings 增字段)
### 问题 3:合规检查"是否已支付经济补偿金"硬阻断
- **现状**:合规检查为必勾才能进入下一步,未勾选时无法继续,且未保存的检查项不进入待办/风险提醒。
- **修复方向**
1. 将"必勾才能下一步"改为"未勾选可下一步,但自动写入待办事项 + 风险提醒"。
2. 待办项关联 `termination_draft`,在 Dashboard/风险中心展示"XX 员工解聘未支付经济补偿金"。
3. 提交审批时若仍存在未闭环必检项,给出强提示但不阻断(除非法定禁止情形,如孕期/工伤)。
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 2 校验逻辑)、`backend/src/services/notification.service.ts``frontend/src/pages/Dashboard.tsx`
### 问题 4:费用结算新增"剩余年假天数"
- **现状**:费用结算步骤仅展示补偿金/代通知金/赔偿金,未涉及年假折算。
- **修复方向**
1. 费用结算区新增"剩余年假天数"字段,支持手动录入或调用考勤模块年假余额(若有)。
2. 按规则计算未休年假折算工资:`日工资 × 剩余年假天数 × 300%`(未休部分)。
3. 折算金额计入"合计应付"。
- **优先级**P2 中
- **涉及文件**`Termination.tsx`step 3)、`backend/src/services/termination.service.ts`cost 计算扩展)
### 问题 5:补偿月数支持 N / N+1 / 2N / 其他
- **现状**`Termination.tsx:1442` 补偿月数取 `costResult.cappedMonths`(系统按工龄自动算 N),不允许选择其他模式。
- **修复方向**
1. 补偿月数改为下拉:N、N+1、2N、其他(自定义输入)。
2. 选择"协商解除"时允许修改月数并实时重算补偿金。
3. 选择"其他"时需填写说明,留痕到 `compensationBreakdown.adjustments`
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 3 补偿金区)、`backend/src/services/termination.service.ts`
### 问题 6:计算基数支持多种来源
- **现状**`Termination.tsx:1443` 计算基数固定取 `costResult.cappedWage`(近 12 月平均工资,且有三倍社平工资封顶)。
- **修复方向**
1. 计算基数改为下拉:近 12 月平均工资(默认)、合同工资、其他(手动填写)。
2. 切换来源后实时重算补偿金,并记录选用算法到草稿。
3. "其他"需填写说明,受三倍社平封顶提示但不强制。
- **优先级**P1 高
- **涉及文件**`Termination.tsx``termination.service.ts`
### 问题 7:手动调整补偿金分项后合计未同步
- **现状**`Termination.tsx:530` 注释提到"系统预估 + 手动调整差额",但 `handleSave`578 行附近)仍使用 `costResult.grandTotal`,未叠加 `compAdjustments`。20260803 问题 13 已识别但未闭环。
- **修复方向**
1. 实际合计 = `costResult.grandTotal + Σ(adjustments.to - adjustments.from)`,保存与确认页统一使用此值。
2. 调整任一分项后实时刷新"合计应付"显示。
3. 后端 `compensationBreakdown.adjustments` 已支持,前端需正确传递并回显。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx``handleSave`、确认步骤、step 3 合计显示)
### 问题 8:工作交接未完成无法下一步
- **现状**`Termination.tsx:552-554` step 4 校验 `work_handover/equipment_return/access_revoke` 三项必须完成才能下一步。
- **修复方向**
1. 改为可下一步,未完成事项自动写入待办(关联离职草稿)。
2. 交接清单前三项标注"离职必办",未完成时在确认页和风险中心强提示。
3. 待办在到达离职日期前持续提醒,超期升级为风险。
- **优先级**P1 高
- **涉及文件**`Termination.tsx`step 4 校验)、`notification.service.ts``Dashboard.tsx`
### 问题 9:离职管理"新建解聘"并入花名册操作栏
- **现状**:花名册操作栏已有"离职"按钮(`Roster.tsx:734-746`,触发 `ResignModal` 创建 RESIGNATION 草稿),但"新建解聘"仍在离职管理列表页独立入口,且仅支持 RESIGNATION 类型。
- **修复方向**
1. 花名册操作栏"离职"按钮扩展为支持所有解聘类型(协商/过错/非过错/裁员/到期/违法),弹出类型选择后跳转 Termination 向导并预填员工。
2. 离职管理列表页移除"新建解聘"按钮,仅保留草稿列表与审批。
- **优先级**P1 高
- **涉及文件**`Roster.tsx``roster/modals.tsx``ResignModal`)、`Termination.tsx`(支持 URL 参数预填)
### 问题 10:解聘驳回后修改内容未更新
- **现状**:驳回后重新修改解聘类型、金额等,系统未按新内容更新(疑似使用旧草稿快照或前端未刷新)。
- **修复方向**
1. 排查 `updateDraft` 是否正确覆盖 `reason/compensationBreakdown/terminationDate`
2. 驳回后再编辑走 `updateDraft`(而非新建),版本号 +1,旧版本留快照。
3. 前端进入驳回草稿时强制重新拉取最新数据。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx`(编辑驳回草稿逻辑)、`backend/src/services/termination.service.ts``backend/src/routes/termination.routes.ts`
### 问题 11:违法解除降低补偿金后无合规风险提醒
- **现状**:选择 ILLEGAL(2N)后手动降低补偿金金额,`acknowledgeRisk` 仅在初始选择违法解除时提示,金额变动后不再校验。
- **修复方向**
1. 实际补偿金 < 法定 2N 时,强制弹出合规风险提醒,需勾选"已知风险并继续"才能下一步。
2. 风险提醒记录到 `compensationBreakdown.riskAcknowledged`,留痕可审计。
3. 同步推送风险中心。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx`step 3 风险校验)、`risk-center`
---
## 二、用工办理 / 合同(问题 12-13、18、20-23、26
### 问题 12:合同续签新增合同开始日期应为上一份结束日期 +1
- **现状**`WorkProcess.tsx:93-99` RENEW 表单 `newStartDate` 为手动填写,无自动推导。
- **修复方向**
1. 选择员工后自动拉取最新已签合同,`newStartDate` 默认 = 上一份合同 `endDate + 1 天`
2. 允许手动覆盖,但偏离时提示。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`RENEW 字段)、`backend/src/services/work-process.service.ts`
### 问题 13:合同结束日期早于开始日期未校验
- **现状**`WorkProcess.tsx` 表单提交前无日期前后关系校验,已产生时间倒置数据。
- **修复方向**
1. 前端表单提交前校验 `endDate >= startDate`,否则阻断并提示。
2. 后端 `work-process.service.ts` 增加同样校验,双保险。
3. 历史倒置数据提供修复脚本/列表提示。
- **优先级**P0 紧急
- **涉及文件**`WorkProcess.tsx``work-process.service.ts`
### 问题 18:员工转正移植到花名册操作栏,替换发薪
- **现状**:花名册操作栏(`Roster.tsx:694-747`)有调薪/发薪/调部门/离职,无转正入口;转正仍在用工办理 CONFIRM 流程。
- **修复方向**
1. 操作栏"发薪"按钮(`Wallet`711-720 行)替换为"转正"按钮(`CheckCircle`),点击弹出转正 Modal(转正日期 + 转正薪资)。
2. 仅试用期员工(`probationInfo.isProbation`)显示转正按钮。
3. 发薪入口保留在薪税管理模块。
- **优先级**P1 高
- **涉及文件**`Roster.tsx``roster/modals.tsx`(新增 `ConfirmModal`
### 问题 19:转正薪资未回写花名册,未与用工协议匹配校验
- **现状**CONFIRM 流程填写 `regularSalary` 后未同步到员工 `monthlySalary`,也未校验与已签合同薪资是否一致。
- **修复方向**
1. 转正完成后同步更新员工 `monthlySalary`,并在花名册明细展示"试用期薪资 → 转正薪资"变更记录。
2. 校验转正薪资与最新合同 `monthlySalary` 是否一致,不一致时提示并支持发起合同变更流程(CHANGE)。
- **优先级**P1 高
- **涉及文件**`work-process.service.ts`CONFIRM 完成回调)、`Roster.tsx``roster/SalaryInfo.tsx`
### 问题 20:开具证明移植到花名册操作栏
- **现状**:操作栏无开具证明入口,需进用工办理 INCOME_CERT/LEAVING_CERT 流程。
- **修复方向**:操作栏新增"证明"按钮(`FileText`),点击弹出证明类型选择(收入证明/离职证明/在职证明),复用 WorkProcess 的 INCOME_CERT/LEAVING_CERT 表单。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``roster/modals.tsx`
### 问题 21:合同续签移植到花名册操作栏
- **现状**:操作栏无续签入口,批量续签在列表顶部(`Roster.tsx:444`),单人续签需进用工办理。
- **修复方向**:操作栏新增"续签"按钮(`Repeat`),仅合同即将到期(≤30 天)或已到期员工显示,点击跳转 WorkProcess RENEW 并预填员工。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``WorkProcess.tsx`(支持 URL 参数预填)
### 问题 22:合同变更选择员工无法查询到对应合同
- **现状**`WorkProcess.tsx:88-92` CHANGE 表单 `contractId``contract-select` 类型,但选择员工后未按员工过滤合同列表,导致查不到。
- **修复方向**
1. `contract-select` 组件接收 `employeeId` 参数,仅返回该员工的有效合同。
2. 后端合同查询接口支持 `employeeId` 过滤。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`contract-select 组件)、`backend/src/routes/contract.routes.ts`
### 问题 23:合同续签原合同 ID 无法查询,改为选择已存在合同
- **现状**`WorkProcess.tsx:95` RENEW 表单 `oldContractId``text` 类型,需手动输入 UUID,无法查询。
- **修复方向**:将 `oldContractId` 改为 `contract-select`(同问题 22),选择员工后下拉展示该员工已存在合同。
- **优先级**P1 高
- **涉及文件**`WorkProcess.tsx`RENEW 字段类型)
### 问题 26:去掉用工办理模块
- **现状**:侧边栏"团队"分组有"用工办理"入口(`SidebarNav.tsx:47`),承载 HIRE/ONBOARD/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/LEAVING_CERT/FLEXIBLE 等流程。
- **修复方向**
1. 将各流程入口下沉到花名册操作栏(转正/续签/变更/开具证明/入职)和独立页面(合同管理/电子签署)。
2. 侧边栏移除"用工办理"菜单项,保留 WorkProcess 页面作为批量流程入口(或迁移到"更多"分组)。
3. 需先完成问题 18/20/21/22/23 的入口下沉,再移除菜单。
- **优先级**:P2 中(依赖前置问题完成)
- **涉及文件**`SidebarNav.tsx``Roster.tsx``WorkProcess.tsx``App.tsx`(路由)
---
## 三、花名册(问题 14-17、24-25
### 问题 14:身份证号全域改为"证件号码"
- **现状**`roster/modals.tsx:687``WorkProcess.tsx:60/109/132/140` 等多处 label 为"身份证号"。
- **修复方向**:全域搜索替换 label "身份证号" → "证件号码",字段名 `idCardNumber` 保持不变(兼容历史数据),同时支持证件类型下拉(身份证/护照/港澳台通行证)。
- **优先级**P2 中
- **涉及文件**`roster/modals.tsx``WorkProcess.tsx``Roster.tsx`(搜索 placeholder)、`SpecialStatus.tsx`
### 问题 15:添加员工根据证件号码进行年龄合规筛查
- **现状**`roster/modals.tsx:557` 已根据身份证号自动计算性别,未做年龄校验。
- **修复方向**
1. 解析证件号码出生日期,计算年龄。
2. 年龄 < 16 岁禁止录入(童工红线),强提示并阻断。
3. 年龄 ≥ 60(男)/55(女干部)/50(女工人)提示已达法定退休年龄,确认后允许录入但标记"退休返聘"。
- **优先级**P1 高
- **涉及文件**`roster/modals.tsx``handleIdCardChange`
### 问题 16:编辑入职日期后状态未变更
- **现状**:花名册明细编辑入职日期后,员工状态未联动(如改为下月 1 日入职,状态应从"在职"变为"待入职")。
- **修复方向**
1. 编辑入职日期时,若新入职日期 > 今天,状态自动设为 `PENDING_ONBOARD`(待入职);若 ≤ 今天,设为 `ACTIVE`
2. 状态变更需二次确认,避免误操作。
3. 变更记录留痕到员工档案。
- **优先级**P1 高
- **涉及文件**`roster/BasicInfo.tsx``backend/src/services/employee.service.ts`
### 问题 17:录入手机号查重
- **现状**`roster/modals.tsx:702` 手机号为选填,无查重;身份证号已有查重(691 行)。
- **修复方向**
1. 手机号录入时调用查重接口,已存在则提示"该手机号已用于 XX 人员"。
2. 允许继续保存(一人多号/家庭号场景),但强提示确认。
- **优先级**P2 中
- **涉及文件**`roster/modals.tsx``backend/src/routes/employee.routes.ts`(新增手机号查重接口)
### 问题 24:花名册多选增加批量转正
- **现状**`Roster.tsx:444-447` 批量操作仅有"批量续签""批量解聘",无批量转正。
- **修复方向**
1. 多选后新增"批量转正"按钮,仅对试用期员工生效。
2. 弹窗统一填写转正日期(默认今天)和转正薪资(可按原薪资倍数/手动逐人填写)。
3. 调用后端批量转正接口,复用问题 18 的转正逻辑。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``roster/modals.tsx``backend/src/services/employee.service.ts`
### 问题 25:花名册多选增加批量开具证明
- **现状**:无批量开具证明入口。
- **修复方向**
1. 多选后新增"批量开具证明"按钮,选择证明类型(在职/收入)后批量生成。
2. 支持批量下载(ZIP)或逐个下载。
- **优先级**P2 中
- **涉及文件**`Roster.tsx``backend/src/services/work-process.service.ts`(批量生成接口)
---
## 四、薪税管理(问题 28
### 问题 28:补偿金批次经济补偿金发放数据读取错误
- **现状**`money/BatchTab.tsx:110` 批次类型支持 `SEVERANCE`(补偿金),但创建补偿金批次时经济补偿金发放数据读取逻辑有误(疑似读取了工资数据或未关联离职草稿的 `compensationBreakdown`)。
- **修复方向**
1. 排查 `createBatch``type=SEVERANCE` 时的数据源,应从已审批通过的 `termination_draft` 读取 `compensationBreakdown.grandTotal`
2. 校验员工范围:仅包含有未发放补偿金的离职员工。
3. 增加预览页展示每位员工的补偿金明细,确认后再创建批次。
- **优先级**P0 紧急
- **涉及文件**`money/BatchTab.tsx``backend/src/services/payroll.service.ts`SEVERANCE 分支)
---
## 五、特殊员工(问题 29
### 问题 29:男职工应无法选择三期
- **现状**`SpecialStatus.tsx:123` 默认 `type: 'PREGNANCY'`,新增/编辑表单未根据员工性别过滤类型,男职工也可选"三期"。
- **修复方向**
1. 选择员工后,若 `gender === '男'`,类型下拉移除"三期"选项或置灰并提示"三期仅适用于女性员工"。
2. 后端保存时增加校验,男职工 + PREGNANCY 组合拒绝并返回 400。
- **优先级**P1 高
- **涉及文件**`SpecialStatus.tsx`(表单 type 下拉)、`backend/src/services/special-status.service.ts`
---
## 六、系统设置 / 组织架构(问题 27)
### 问题 27:增加简单组织架构,支持三步以内审批流转
- **现状**:系统无组织架构模块,`LeaveApproval.tsx:90` 仅有简单审批,无层级流转;`settingsApi.org()` 仅返回企业基本信息。当前员工仅有 `department`(字符串)和 `position`(字符串,前端 label "职务/岗位")两个字段,无上下级关系。
- **职位/岗位设计决策**
- **现状**:数据库只有一个 `position` 字段(`schema.prisma:225`,注释"岗位"),前端 label 统一为"职务/岗位"`roster/modals.tsx:686``roster/BasicInfo.tsx:153/328`),已合并为一个字段。
- **建议方案 A(推荐,保留一个字段)**:维持 `position` 单字段,label 保持"职务/岗位"。组织架构中"岗位"作为独立实体管理(岗位字典,含岗位名称、职级、编制人数),员工 `position` 关联到岗位字典。优点:改动小,兼容历史数据,符合当前使用习惯。
- **方案 B(拆分两个字段)**:新增 `jobTitle`(职位,如"经理/主管/专员"+ `position`(岗位,如"前端工程师")。优点:职级体系更清晰;缺点:需改 schema + 全域表单 + 历史 position 数据需清洗归类,工作量大。
- **结论**:建议采用方案 A,组织架构模块中"岗位"独立建表(`Position` 字典),员工 `position` 字段值关联岗位字典 ID 或保持文本(兼容旧数据),审批流按"直属上级 → 部门负责人"两级流转,无需引入职级。
- **修复方向**
1. 系统设置新增"组织架构"子页:树形部门(`Department` 自引用 parent+ 岗位字典(`Position`,含名称/所属部门/编制)+ 上下级关系(`Employee.supervisorId`)。
2. 新建简单审批流配置:最多 3 步(发起人 → 直属上级 → 部门负责人),支持按流程类型(休假/离职/调薪)配置是否启用某步。
3. 现有 `LeaveApproval` 接入审批流引擎,离职/调薪等流程可选启用。
4. 数据模型:`Department`(树形,parent 自引用)、`Position`(岗位字典)、`Employee.supervisorId``ApprovalFlow`type + steps)。
- **优先级**:P3 规划(新模块,建议单独排期)
- **涉及文件**`Settings.tsx`、新增 `pages/OrgChart.tsx``backend/prisma/schema.prisma``backend/src/services/approval.service.ts`
---
## 七、全域问题(问题 1、30、31)
### 问题 1"劳动合同"调整为更宽泛的"用工关系"
- **现状**:系统多处文案使用"劳动合同"(合同管理、离职管理、WorkProcess 等)。
- **修复方向**
1. 全域文案审计:将面向用户的"劳动合同"在合适场景改为"用工关系"或"用工协议"(涵盖劳动合同/劳务协议/实习协议/灵活用工协议)。
2. 数据层 `Contract` 模型保持不变,新增 `contractCategory` 字段区分劳动关系类型。
3. 注意法律文书模板中的"劳动合同"为法定术语,不可改。
- **优先级**P2 中
- **涉及文件**`SidebarNav.tsx``Contracts.tsx``WorkProcess.tsx``Termination.tsx` 等文案
### 问题 30:全域保存草稿后部分录入数据未保存(BUG)
- **现状**:多个流程页面支持"保存草稿",但部分字段(如 `compAdjustments``handoverItems` 备注、`checklistOverrides`)未持久化。
- **修复方向**
1. 排查各流程 `saveDraft` 的 payload,确保覆盖所有前端 state。
2. 后端 `draft` 表 schema 检查是否有字段缺失(`compensationBreakdown.adjustments``handoverItems.remark` 等)。
3. 增加"草稿完整性校验":保存后立即回读对比,缺失字段告警。
4. 重点排查:Termination(补偿金调整/交接备注)、WorkProcess(自定义字段)。
- **优先级**P0 紧急
- **涉及文件**`Termination.tsx``handleSaveDraft`)、`WorkProcess.tsx``backend/src/services/termination.service.ts``backend/src/services/work-process.service.ts`
### 问题 31:单立户客服端
#### 已实现部分(平台管理端,代码完整可运行)
系统已有完整的"平台管理端"(Platform),前后端代码均已实现:
**前端(4 页面 + 1 侧边栏)**
- `frontend/src/pages/platform/PlatformLogin.tsx` — 独立登录页
- `frontend/src/pages/platform/PlatformDashboard.tsx` — 数据总览(企业数/用户数/员工数/合同数/工资条数 + 套餐分布 + 最近注册企业)
- `frontend/src/pages/platform/PlatformOrgs.tsx` — 企业租户管理(创建/搜索/编辑套餐/删除/管理员账号管理)
- `frontend/src/pages/platform/PlatformUsers.tsx` — 用户管理(启用/禁用)
- `frontend/src/components/layout/PlatformSidebar.tsx` — 独立侧边栏(带 ADMIN 徽章)
- `frontend/src/lib/api-services.ts:938-965``platformApi` 完整定义
- `frontend/src/App.tsx:217-220` — 四条路由注册 + `PlatformRoute` 角色校验(`SUPER_ADMIN`
**后端(1 路由文件 437 行 + 1 schema**
- `backend/src/routes/platform.routes.ts` — 完整实现 11 个接口:
- `GET /platform/dashboard` — 平台总览数据
- `GET /platform/orgs` — 企业列表(分页+搜索+套餐筛选)
- `POST /platform/orgs` — 创建企业(含管理员账号自动创建)
- `GET /platform/orgs/:id` — 企业详情(含用户列表)
- `PUT /platform/orgs/:id` — 编辑企业(套餐/上限/联系人)
- `PUT /platform/orgs/:id/admin` — 编辑企业管理员(姓名/手机号/重置密码)
- `DELETE /platform/orgs/:id` — 删除企业(级联)
- `GET /platform/users` — 全平台用户列表
- `PUT /platform/users/:id/toggle` — 启用/禁用用户
- `GET /platform/admins` — 平台管理员列表
- `POST /platform/admins` — 创建平台管理员
- `backend/src/schemas/platform.schema.ts` — 入参校验
- `backend/src/middleware/auth.ts``platformAdminMiddleware` 权限校验
**数据模型**
- `Organization` 模型支持多租户(`plan`/`maxEmployees`/`city`/`contactName`/`contactPhone`
- `User.role``SUPER_ADMIN` 角色,`orgId` 为 null(平台管理员不归属任何租户)
- `backend/prisma/seed-multi-org.ts` — 多租户种子数据
**结论**:单立户服务(开户+租户管理+用户管理+数据总览)**已完整实现**,CSV 标注为"调整"类型也印证了该功能已存在。
#### 可增强部分(客服工作台,尚未实现)
当前平台管理端是"运营管理后台",定位为超级管理员/运营使用。如需扩展为客服人员日常服务客户的工作台,以下功能尚未实现:
| 增强项 | 现状 | 说明 |
|--------|------|------|
| 工单管理 | 缺失 | 无 `Ticket` 模型、无工单页面,客服无法接收/分派/跟进客户问题 |
| 客户会话 | 缺失 | 无会话模块,客服无法与客户企业管理员实时沟通或留言 |
| 租户数据穿透 | 缺失 | `SUPER_ADMIN` 仅能看租户列表和统计,无法穿透查看指定租户的花名册/合同/薪税/风险等业务数据 |
| 协助操作 | 部分已有 | 管理员重置密码已有,代客户发起流程/调整套餐等未实现 |
| AI 辅助 | 缺失 | 当前 AI 仅在企业端,客服侧无 AI 问答辅助 |
| 操作日志 | 缺失 | 当前 `AuditLog` 仅在企业端,客服操作无留痕 |
**增强修复方向(如需)**
1. 新增角色 `SUPPORT`(客服),独立路由 `/support/*`,独立登录页 `/support/login`
2. 客服端侧边栏含:工单中心、客户会话、租户数据查看、协助操作、AI 辅助、操作日志。
3. 数据模型新增:`Ticket`(工单:标题/内容/状态/优先级/归属租户/处理人)、`TicketMessage`(工单回复)、`ChatSession`(客户会话)。
4. 租户数据穿透:客服选择租户后,以"只读+代操作"模式访问该租户的业务数据(复用现有 API,请求头带 `X-Support-Tenant-Id`,后端中间件切换租户上下文)。
5. AI 辅助复用现有 AI 接口,独立会话隔离。
6. MVP 建议:先做工单 + 租户数据查看 + AI 辅助三项。
- **优先级**:核心功能已完成;增强部分 P3 规划(新模块,建议单独排期)
- **涉及文件(增强部分)**:新增 `frontend/src/pages/support/*``frontend/src/components/layout/SupportSidebar.tsx``backend/prisma/schema.prisma`Ticket/ChatSession 模型)、`backend/src/routes/support.routes.ts``backend/src/middleware/support-tenant.ts`
---
## 优先级汇总
| 优先级 | 问题 | 说明 |
|--------|------|------|
| **P0 紧急** | 7、10、11、13、28、30 | 数据丢失/业务正确性/合规风险 |
| **P1 高** | 2、3、5、6、8、9、12、15、16、18、19、22、23、29 | 流程阻塞或合规校验缺失 |
| **P2 中** | 1、4、14、17、20、21、24、25、26 | 体验优化/功能增强 |
| **P3 规划** | 27、31增强 | 新模块(组织架构+审批流 / 客服工作台增强),需单独排期 |
| **已完成** | 31核心 | 单立户服务(平台管理端)已完整实现,前后端代码可运行 |
---
## 建议执行顺序
1. **第一批(P0,立即)**:问题 30(草稿丢失)→ 问题 7(补偿金合计)→ 问题 10(驳回未更新)→ 问题 11(违法解除风险)→ 问题 13(合同日期校验)→ 问题 28(补偿金批次数据)
2. **第二批(P1,本迭代)**:离职管理联动(2/3/5/6/8/9)→ 花名册校验(15/16)→ 用工办理入口下沉(18/19/22/23)→ 特殊员工性别校验(29)
3. **第三批(P2,排期)**:花名册批量操作(24/25)→ 文案统一(1/14)→ 入口下沉收尾(20/21/26)→ 年假折算(4)→ 手机号查重(17)
4. **第四批(P3,规划)**:组织架构与审批流(27)→ 客服工作台增强(31增强,MVP = 工单 + 租户数据查看 + AI 辅助)
- 问题 31 核心功能(单立户服务/平台管理端)已完整实现,无需开发
---
## 备注
- 本清单基于当期代码梳理,部分"根因"为基于代码静态分析的推断,实际修复前需运行复现确认。
- 涉及数据库 schema 变更的(问题 2/27/31),需走 migration + 回滚方案,符合数据 8 铁律。
- 涉及文案全域替换的(问题 1/14),需同步更新 HelpModal、OnboardingGuide 等帮助文案。
- 问题 26(去掉用工办理模块)依赖问题 18/20/21/22/23 完成,不可先行移除。
+751
View File
@@ -0,0 +1,751 @@
# 20260815 优化执行计划
> 基于 `20260815-优化.md`(31 条优化项)制定的可执行任务清单。
> 覆盖范围:P0(6 条)+ P1(14 条)+ P29 条)+ P3(2 项,含问题 27 方案 A + 问题 31 增强全部 6 项)。
> 技术栈:React 18 + Vite 5 / Express + Prisma + PostgreSQL / 见 `run.md`。
---
## 执行状态总览(2026-08-15 更新)
| 批次 | 优先级 | 任务数 | 完成数 | 状态 |
|------|--------|--------|--------|------|
| 第一批 | P0 紧急 | 6 条 | 6 条 | ✅ 全部完成 |
| 第二批 | P1 高 | 14 条 | 14 条 | ✅ 全部完成 |
| 第三批 | P2 中 | 9 条 | 9 条 | ✅ 全部完成 |
| 第四批 | P3 规划 | 2 项 | 2 项 | ✅ 全部完成 |
| **合计** | | **31 条** | **31 条** | **✅ 100% 完成** |
> 编译状态:前后端 TypeScript 编译均 0 错误。
> 数据库:Prisma schema 已同步(`prisma db push`),新增 8 个模型 + 2 个字段 + 1 个枚举值。
---
## 批次总览
| 批次 | 优先级 | 任务数 | 目标 | 依赖 |
|------|--------|--------|------|------|
| 第一批 | P0 紧急 | 6 条 | 数据丢失/业务正确性/合规风险修复 | 无 |
| 第二批 | P1 高 | 14 条 | 流程阻塞/合规校验缺失修复 | 第一批完成 |
| 第三批 | P2 中 | 9 条 | 体验优化/功能增强 | 第二批完成(26 依赖 18/20/21/22/23 |
| 第四批 | P3 规划 | 2 项 | 组织架构+审批流 / 客服工作台增强 | 第三批完成 |
---
## 第一批:P0 紧急(6 条)
### TASK-001:全域保存草稿后部分录入数据未保存(问题 30)
- **目标**:确保所有流程页面的"保存草稿"功能完整持久化全部前端 state
- **对应需求**:问题 30
- **验收标准**
- [x] Termination 草稿保存后,`compAdjustments`(补偿金手动调整记录)可回读
- [x] Termination 草稿保存后,`handoverItems` 的 remark(备注)可回读
- [x] Termination 草稿保存后,`checklistOverrides`(合规检查覆盖原因)可回读
- [x] WorkProcess 草稿保存后,自定义字段可回读
- [x] 保存后立即回读对比,缺失字段时控制台告警
- **依赖**:无
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx``handleSaveDraft` payload 补全)
- `frontend/src/pages/WorkProcess.tsx`(草稿 payload 补全)
- `backend/src/services/termination.service.ts`createDraft/updateDraft 字段映射)
- `backend/src/services/work-process.service.ts`(草稿字段映射)
- **测试要求**
- 手工验证:Termination 填写补偿金调整 + 交接备注 → 保存草稿 → 重新进入 → 数据完整
- 手工验证:WorkProcess 填写自定义字段 → 保存草稿 → 重新进入 → 数据完整
### TASK-002:手动调整补偿金分项后合计未同步(问题 7)
- **目标**:补偿金手动调整后,合计应付实时更新,保存与确认页使用实际合计值
- **对应需求**:问题 7
- **验收标准**
- [x] 调整任一补偿金分项后,"合计应付"显示实时刷新
- [x] 实际合计 = `costResult.grandTotal + Σ(adjustments.to - adjustments.from)`
- [x] `handleSave` 使用实际合计值,不再使用 `costResult.grandTotal`
- [x] 确认步骤显示"系统预估 ¥X + 手动调整 ¥Y = 实际补偿 ¥Z"
- [x] 后端 `compensationBreakdown.adjustments` 正确存储并回显
- **依赖**TASK-001(草稿需先能保存 adjustments
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx``handleSave`、确认步骤、step 3 合计显示)
- `backend/src/services/termination.service.ts`adjustments 存储与返回)
- **测试要求**
- 手工验证:系统预估补偿金 ¥50000 → 手动调整 severance ¥50000→¥45000 → 合计显示 ¥45000 → 保存 → 确认页显示 ¥45000
### TASK-003:解聘驳回后修改内容未更新(问题 10)
- **目标**:驳回后重新编辑草稿,修改的解聘类型/金额/日期等正确覆盖更新
- **对应需求**:问题 10
- **验收标准**
- [x] 驳回草稿再编辑走 `updateDraft`(而非新建),版本号 +1
- [x] `updateDraft` 正确覆盖 `reason``compensationBreakdown``terminationDate`
- [x] 前端进入驳回草稿时强制重新拉取最新数据(不使用缓存)
- [x] 旧版本留快照,可查看历史版本
- [x] 修改解聘类型后,补偿金按新类型重新计算
- **依赖**:TASK-002(补偿金合计逻辑需先正确)
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`(编辑驳回草稿逻辑、进入时强制刷新)
- `backend/src/services/termination.service.ts`updateDraft 覆盖逻辑、版本快照)
- `backend/src/routes/termination.routes.ts`update 接口校验)
- **测试要求**
- 手工验证:创建解聘草稿(协商解除 ¥50000)→ 审批驳回 → 修改为过错解除 ¥0 → 保存 → 重新进入显示 ¥0
### TASK-004:违法解除降低补偿金后无合规风险提醒(问题 11)
- **目标**:违法解除(2N)场景下,实际补偿金低于法定 2N 时强制风险提醒
- **对应需求**:问题 11
- **验收标准**
- [x] 选择 ILLEGAL 后,实际补偿金 < 法定 2N 时,强制弹出合规风险提醒
- [x] 需勾选"已知风险并继续"才能进入下一步
- [x] 风险提醒记录到 `compensationBreakdown.riskAcknowledged`(含时间戳、用户、金额对比)
- [x] 同步推送风险中心,生成风险项
- [x] 金额变动后重新校验(不仅初始选择时)
- **依赖**TASK-002(补偿金合计逻辑)
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 风险校验逻辑)
- `backend/src/services/termination.service.ts`riskAcknowledged 存储)
- `backend/src/services/risk.service.ts`(风险项生成)
- **测试要求**
- 手工验证:选择违法解除 → 法定 2N=¥100000 → 手动改为 ¥80000 → 弹出风险提醒 → 必须勾选才能下一步
### TASK-005:合同结束日期早于开始日期未校验(问题 13)
- **目标**:合同开始/结束日期前后关系校验,杜绝时间倒置数据
- **对应需求**:问题 13
- **验收标准**
- [x] 前端 WorkProcess 所有含日期的表单(HIRE/ONBOARD/CUSTOM_CONTRACT/CHANGE/RENEW/SUSPEND)提交前校验 `endDate >= startDate`
- [x] 校验失败时阻断提交并提示"结束日期不能早于开始日期"
- [x] 后端 `work-process.service.ts` 增加同样校验,双保险
- [x] 历史倒置数据:提供查询列表提示(不自动修复,由用户确认后修复)
- **依赖**:无
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/WorkProcess.tsx`(表单提交前校验)
- `backend/src/services/work-process.service.ts`(后端校验)
- `backend/src/routes/contract.routes.ts`(历史倒置数据查询接口)
- **测试要求**
- 手工验证:新建合同 → 开始日期 2026-06-01 → 结束日期 2026-05-31 → 提交被阻断
- 手工验证:后端直接调用 API 传倒置日期 → 返回 400
### TASK-006:补偿金批次经济补偿金发放数据读取错误(问题 28)
- **目标**:补偿金批次(SEVERANCE 类型)正确从离职草稿读取补偿金数据
- **对应需求**:问题 28
- **验收标准**
- [x] `createBatch``type=SEVERANCE` 时,数据源从已审批通过的 `termination_draft` 读取 `compensationBreakdown.grandTotal`
- [x] 员工范围:仅包含有未发放补偿金的离职员工
- [x] 创建前增加预览页,展示每位员工的补偿金明细(补偿金/代通知金/赔偿金/合计)
- [x] 确认后再创建批次
- [x] 不再读取工资数据作为补偿金
- **依赖**:TASK-002(补偿金合计需先正确)
- **优先级**P0
- **阶段**:第一批
- **涉及文件**
- `frontend/src/pages/money/BatchTab.tsx`(SEVERANCE 创建流程、预览页)
- `backend/src/services/payroll.service.ts`SEVERANCE 分支数据源修正)
- `backend/src/routes/payroll2.routes.ts`(预览接口)
- **测试要求**
- 手工验证:员工 A 离职补偿金 ¥50000(已审批)→ 创建补偿金批次 → 预览显示 ¥50000 → 确认创建 → 批次数据正确
---
## 第二批:P1 高(14 条)
### TASK-007:离职日期调整后联动社保/公积金截止年月(问题 2)
- **目标**:离职日期变更时自动推导社保/公积金截止月,支持 15 日前后规则可配置
- **对应需求**:问题 2
- **验收标准**
- [x] 离职日期变更时自动回填社保截止月、公积金截止月
- [x] 规则:每月 cutoffDay(默认 15)日前离职 → 截止月 = 离职月 - 1;cutoffDay 日后离职 → 截止月 = 离职月
- [x] `org_settings` 新增 `socialInsCutoffDay` 字段(默认 15),系统设置可配置
- [x] 保留手动覆盖入口(用户可手动修改截止月)
- [x] 手动覆盖时提示"与自动推导不一致"
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`(离职日期变更联动)
- `backend/src/services/termination.service.ts`(自动推导逻辑)
- `backend/prisma/schema.prisma`Organization 新增 `socialInsCutoffDay Int @default(15)`
- `frontend/src/pages/Settings.tsx`(配置入口)
- **DB 变更**`Organization` 新增 `socialInsCutoffDay` 字段,migration + 回滚方案
- **测试要求**
- 手工验证:离职日期 2026-08-1010 日 < 15 日)→ 社保截止 2026-07 → 公积金截止 2026-07
- 手工验证:离职日期 2026-08-2020 日 > 15 日)→ 社保截止 2026-08 → 公积金截止 2026-08
- 手工验证:修改 cutoffDay 为 10 → 离职日期 2026-08-15 → 社保截止 2026-08
### TASK-008:合规检查"已支付经济补偿金"改为软阻断+待办(问题 3)
- **目标**:合规检查未勾选项可下一步,但自动写入待办和风险提醒
- **对应需求**:问题 3
- **验收标准**
- [x] step 2 合规检查未勾选 required 项时,可进入下一步(不硬阻断)
- [x] 未勾选的 required 项自动写入待办事项,关联 `termination_draft`
- [x] Dashboard/风险中心展示"XX 员工解聘未支付经济补偿金"等待办
- [x] 提交审批时若仍存在未闭环必检项,给出强提示但不阻断
- [x] 法定禁止情形(孕期/工伤/医疗期)仍硬阻断
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 2 校验逻辑改为软阻断)
- `backend/src/services/notification.service.ts`(待办生成)
- `backend/src/services/risk.service.ts`(风险提醒生成)
- `frontend/src/pages/Dashboard.tsx`(待办展示)
- **测试要求**
- 手工验证:合规检查不勾"已支付经济补偿金" → 可下一步 → Dashboard 出现待办"XX 解聘未支付经济补偿金"
### TASK-009:补偿月数支持 N / N+1 / 2N / 其他(问题 5
- **目标**:补偿月数改为下拉选择,支持手动修改并重算补偿金
- **对应需求**:问题 5
- **验收标准**
- [x] 补偿月数改为下拉:N(默认)、N+1、2N、其他(自定义输入)
- [x] 选择"协商解除"时允许修改月数
- [x] 月数变更后实时重算补偿金 = 月数 × 计算基数
- [x] 选择"其他"时需填写说明,留痕到 `compensationBreakdown.adjustments`
- [x] 系统计算值 N 仍作为默认推荐显示
- **依赖**TASK-002(补偿金合计逻辑)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 补偿月数下拉)
- `backend/src/services/termination.service.ts`(月数模式存储)
- **测试要求**
- 手工验证:系统算 N=3 → 选择 N+1 → 月数变 4 → 补偿金重算 → 选择"其他"→ 输入 5 + 说明 → 补偿金重算
### TASK-010:计算基数支持多种来源(问题 6)
- **目标**:计算基数改为下拉选择,支持多种来源切换
- **对应需求**:问题 6
- **验收标准**
- [x] 计算基数改为下拉:近 12 月平均工资(默认)、合同工资、其他(手动填写)
- [x] 切换来源后实时重算补偿金
- [x] 选用算法记录到草稿 `compensationBreakdown.wageBaseType`
- [x] "其他"需填写说明,受三倍社平封顶提示但不强制
- [x] 近 12 月平均工资仍显示三倍社平封顶后的值
- **依赖**:TASK-009(月数与基数联动重算)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 计算基数下拉)
- `backend/src/services/termination.service.ts`wageBaseType 存储)
- **测试要求**
- 手工验证:默认近 12 月平均 ¥10000 → 切换为合同工资 ¥8000 → 补偿金重算 → 切换为"其他"→ 手动输入 ¥12000 + 说明 → 重算
### TASK-011:工作交接未完成改为软阻断+待办(问题 8)
- **目标**:工作交接未完成可下一步,未完成事项进入待办提醒
- **对应需求**:问题 8
- **验收标准**
- [x] step 4 工作交接未完成时可进入下一步(不硬阻断)
- [x] 交接清单前三项(work_handover/equipment_return/access_revoke)标注"离职必办"
- [x] 未完成事项自动写入待办,关联离职草稿
- [x] 确认页和风险中心强提示未完成交接项
- [x] 待办在到达离职日期前持续提醒,超期升级为风险
- **依赖**TASK-008(待办机制复用)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`(step 4 校验改为软阻断、确认页提示)
- `backend/src/services/notification.service.ts`(待办生成与提醒)
- **测试要求**
- 手工验证:工作交接不勾"工作交接完成" → 可下一步 → 确认页提示"3 项未完成" → Dashboard 出现待办
### TASK-012:离职管理"新建解聘"并入花名册操作栏(问题 9)
- **目标**:花名册操作栏"离职"按钮支持所有解聘类型,离职管理列表页移除新建入口
- **对应需求**:问题 9
- **验收标准**
- [x] 花名册操作栏"离职"按钮点击后弹出解聘类型选择(协商/过错/非过错/裁员/到期/违法/主动离职)
- [x] 选择类型后跳转 Termination 向导,URL 带参数预填员工 ID 和解聘类型
- [x] Termination 向导支持从 URL 参数读取 `employeeId``reason` 自动预填
- [x] 离职管理列表页移除"新建解聘"按钮,仅保留草稿列表与审批
- [x] 主动离职(RESIGNATION)仍走原 ResignModal 快速流程
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏离职按钮扩展)
- `frontend/src/pages/roster/modals.tsx`ResignModal 扩展或新增类型选择 Modal)
- `frontend/src/pages/Termination.tsx`URL 参数预填支持)
- **测试要求**
- 手工验证:花名册点击员工"离职" → 弹出类型选择 → 选择"协商解除" → 跳转 Termination → 员工和类型已预填
### TASK-013:合同续签新增合同开始日期自动推导(问题 12)
- **目标**:续签合同时,新合同开始日期默认 = 上一份合同结束日期 + 1 天
- **对应需求**:问题 12
- **验收标准**
- [x] RENEW 表单选择员工后,自动拉取最新已签合同
- [x] `newStartDate` 默认 = 上一份合同 `endDate + 1 天`
- [x] 允许手动覆盖,偏离时提示"建议开始日期为 YYYY-MM-DD"
- [x] 无历史合同时默认为今天
- **依赖**TASK-005(日期校验)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/WorkProcess.tsx`RENEW 字段联动)
- `backend/src/services/work-process.service.ts`(查询员工最新合同)
- **测试要求**
- 手工验证:员工合同结束日期 2026-05-31 → 新增续签 → 新合同开始日期自动填 2026-06-01
### TASK-014:添加员工根据证件号码进行年龄合规筛查(问题 15)
- **目标**:解析证件号码自动计算年龄,童工阻断、退休提示
- **对应需求**:问题 15
- **验收标准**
- [x] 解析身份证号出生日期,计算年龄
- [x] 年龄 < 16 岁:禁止录入,强提示"禁止使用童工(未满 16 周岁)",阻断保存
- [x] 年龄 ≥ 60(男)/55(女干部)/50(女工人):提示"已达法定退休年龄",确认后允许录入,自动标记 `retirementFlag`
- [x] 年龄校验在 `handleIdCardChange` 中实时执行
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/roster/modals.tsx``handleIdCardChange` 增加年龄校验)
- `backend/src/routes/employee.routes.ts`(后端年龄校验双保险)
- **测试要求**
- 手工验证:身份证号出生日期 2015-01-01(11 岁)→ 阻断提示"禁止使用童工"
- 手工验证:身份证号出生日期 1965-01-01(61 岁男)→ 提示"已达法定退休年龄" → 确认后可录入
### TASK-015:编辑入职日期后状态联动变更(问题 16)
- **目标**:编辑入职日期后,员工状态自动联动
- **对应需求**:问题 16
- **验收标准**
- [x] 编辑入职日期 > 今天 → 状态自动设为 `PENDING_ONBOARD`(待入职)
- [x] 编辑入职日期 ≤ 今天 → 状态自动设为 `ACTIVE`(在职)
- [x] 状态变更需二次确认弹窗"入职日期变更将导致状态从 XX 变为 YY,是否继续?"
- [x] 变更记录留痕到员工档案(操作日志)
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/roster/BasicInfo.tsx`(入职日期编辑联动状态确认)
- `backend/src/services/employee.service.ts`(状态联动逻辑)
- **测试要求**
- 手工验证:在职员工入职日期改为下月 1 日 → 弹窗确认 → 状态变为"待入职"
- 手工验证:待入职员工入职日期改为今天 → 弹窗确认 → 状态变为"在职"
### TASK-016:员工转正移植到花名册操作栏(问题 18)
- **目标**:花名册操作栏"发薪"按钮替换为"转正"按钮
- **对应需求**:问题 18
- **验收标准**
- [x] 操作栏"发薪"按钮(Wallet 图标)替换为"转正"按钮(CheckCircle 图标)
- [x] 仅试用期员工(`probationInfo.isProbation === true`)显示转正按钮
- [x] 点击弹出转正 Modal(转正日期 + 转正薪资)
- [x] 转正完成后员工状态从试用期转为正式
- [x] 发薪入口保留在薪税管理模块(移除操作栏发薪按钮不影响薪税功能)
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏按钮替换)
- `frontend/src/pages/roster/modals.tsx`(新增 `ConfirmModal`
- `backend/src/services/employee.service.ts`(转正接口)
- **测试要求**
- 手工验证:试用期员工操作栏显示"转正"按钮 → 点击 → 填写转正日期+薪资 → 确认 → 状态变为正式
### TASK-017:转正薪资回写花名册+用工协议匹配校验(问题 19)
- **目标**:转正完成后同步更新员工薪资,校验与合同薪资一致性
- **对应需求**:问题 19
- **验收标准**
- [x] 转正完成后同步更新员工 `monthlySalary`
- [x] 花名册明细展示"试用期薪资 → 转正薪资"变更记录
- [x] 校验转正薪资与最新合同 `monthlySalary` 是否一致
- [x] 不一致时提示"转正薪资 ¥X 与合同薪资 ¥Y 不一致,是否发起合同变更?"
- [x] 支持一键跳转合同变更流程(CHANGE)
- **依赖**TASK-016(转正功能)
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `backend/src/services/work-process.service.ts`CONFIRM 完成回调更新薪资)
- `frontend/src/pages/roster/SalaryInfo.tsx`(薪资变更记录展示)
- `frontend/src/pages/Roster.tsx`(不一致提示与跳转)
- **测试要求**
- 手工验证:试用期薪资 ¥8000 → 转正填写 ¥10000 → 确认 → 花名册薪资更新为 ¥10000 → 如合同薪资 ¥9000 → 提示不一致
### TASK-018:合同变更/续签选择员工后过滤合同列表(问题 22+23)
- **目标**contract-select 组件按员工过滤合同,RENEW 的 oldContractId 改为下拉选择
- **对应需求**:问题 22 + 问题 23
- **验收标准**
- [x] `contract-select` 组件接收 `employeeId` 参数,仅返回该员工的有效合同
- [x] CHANGE 表单选择员工后,合同下拉仅显示该员工合同
- [x] RENEW 表单 `oldContractId``text` 改为 `contract-select`,选择员工后下拉展示该员工已存在合同
- [x] 后端合同查询接口支持 `employeeId` 过滤
- [x] 合同下拉显示合同名称 + 起止日期 + 状态
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/WorkProcess.tsx`contract-select 组件改造、RENEW 字段类型)
- `backend/src/routes/contract.routes.ts`employeeId 过滤)
- **测试要求**
- 手工验证:合同变更 → 选择员工 A → 合同下拉仅显示员工 A 的合同
- 手工验证:合同续签 → 选择员工 A → 原合同下拉显示员工 A 的已签合同(非 UUID 手输)
### TASK-019:男职工无法选择三期(问题 29)
- **目标**:特殊员工表单根据性别过滤类型,男职工不可选三期
- **对应需求**:问题 29
- **验收标准**
- [x] 选择员工后,若 `gender === '男'`,类型下拉移除"三期"选项或置灰
- [x] 置灰时提示"三期仅适用于女性员工"
- [x] 后端保存时增加校验:男职工 + PREGNANCY 组合返回 400
- [x] 编辑已有记录时,如原记录为三期但员工为男性(历史脏数据),提示修正
- **依赖**:无
- **优先级**P1
- **阶段**:第二批
- **涉及文件**
- `frontend/src/pages/SpecialStatus.tsx`(表单 type 下拉过滤)
- `backend/src/services/special-status.service.ts`(后端校验)
- **测试要求**
- 手工验证:选择男员工 → 类型下拉无"三期" → 后端直接传 PREGNANCY → 返回 400
---
## 第三批:P2 中(9 条)
### TASK-020"劳动合同"调整为"用工关系"(问题 1
- **目标**:全域面向用户文案中"劳动合同"在合适场景改为"用工关系"或"用工协议"
- **对应需求**:问题 1
- **验收标准**
- [x] 全域文案审计完成,列出所有"劳动合同"出现位置
- [x] 面向用户的菜单/标题/提示中"劳动合同"改为"用工关系"或"用工协议"
- [x] 法律文书模板中的"劳动合同"保持不变(法定术语)
- [x] `Contract` 模型新增 `contractCategory` 字段(LABOR_CONTRACT/LABOR_AGREEMENT/INTERNSHIP/FLEXIBLE
- [x] HelpModal、OnboardingGuide 帮助文案同步更新
- **依赖**:无
- **优先级**P2
- **阶段**:第三批
- **涉及文件**`SidebarNav.tsx``Contracts.tsx``WorkProcess.tsx``Termination.tsx``HelpModal.tsx``OnboardingGuide.tsx`
- **DB 变更**`LaborContract` 新增 `contractCategory` 字段(nullable,默认 LABOR_CONTRACT
- **测试要求**:全域搜索"劳动合同"确认无遗漏(法律文书模板除外)
### TASK-021:费用结算新增"剩余年假天数"(问题 4
- **目标**:费用结算区新增年假折算,计入合计应付
- **对应需求**:问题 4
- **验收标准**
- [x] 费用结算区新增"剩余年假天数"字段(手动录入或调用考勤年假余额)
- [x] 按规则计算未休年假折算工资:`日工资 × 剩余年假天数 × 300%`
- [x] 日工资 = 月工资 / 21.75
- [x] 折算金额计入"合计应付"
- [x] 年假折算明细在确认页和文书中展示
- **依赖**TASK-002(合计计算逻辑)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Termination.tsx`step 3 年假字段)
- `backend/src/services/termination.service.ts`(年假折算计算)
- **测试要求**
- 手工验证:剩余年假 5 天 → 日工资 ¥460(¥10000/21.75)→ 折算 ¥69005×460×300%)→ 合计应付包含 ¥6900
### TASK-022:身份证号全域改为"证件号码"(问题 14
- **目标**:全域 label "身份证号"改为"证件号码",支持证件类型下拉
- **对应需求**:问题 14
- **验收标准**
- [x] 全域 label "身份证号"替换为"证件号码"
- [x] 字段名 `idCardNumber` 保持不变(兼容历史数据)
- [x] 新增证件类型下拉(身份证/护照/港澳台通行证/其他),存储到 `idType` 字段
- [x] 证件类型为"身份证"时保留 18 位校验 + 性别/年龄自动推导
- [x] 非身份证类型时跳过 18 位校验
- [x] 搜索 placeholder 同步更新
- **依赖**:TASK-014(年龄校验需适配证件类型)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**`roster/modals.tsx``WorkProcess.tsx``Roster.tsx``SpecialStatus.tsx``BasicInfo.tsx`
- **DB 变更**`Employee` 新增 `idType` 字段(nullable,默认 ID_CARD
- **测试要求**:全域搜索"身份证号"确认无遗漏
### TASK-023:录入手机号查重(问题 17)
- **目标**:手机号录入时查重,已存在提示"该手机号已用于 XX 人员"
- **对应需求**:问题 17
- **验收标准**
- [x] 手机号录入时调用查重接口
- [x] 已存在则提示"该手机号已用于 XX 人员"
- [x] 允许继续保存(一人多号/家庭号场景),但强提示确认
- [x] 后端新增 `/employees/check-phone` 接口
- [x] 手机号格式校验(11 位数字)
- **依赖**:无
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/roster/modals.tsx`(手机号查重)
- `backend/src/routes/employee.routes.ts`(新增查重接口)
- **测试要求**
- 手工验证:录入已存在手机号 → 提示"该手机号已用于 张三 人员" → 可继续保存
### TASK-024:开具证明移植到花名册操作栏(问题 20)
- **目标**:操作栏新增"证明"按钮,支持快速开具证明
- **对应需求**:问题 20
- **验收标准**
- [x] 操作栏新增"证明"按钮(FileText 图标)
- [x] 点击弹出证明类型选择(收入证明/离职证明/在职证明)
- [x] 复用 WorkProcess 的 INCOME_CERT/LEAVING_CERT 表单
- [x] 生成后支持下载/打印
- **依赖**:无
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏新增按钮)
- `frontend/src/pages/roster/modals.tsx`(证明类型选择 Modal
- **测试要求**
- 手工验证:点击操作栏"证明" → 选择"收入证明" → 填写信息 → 生成 → 可下载
### TASK-025:合同续签移植到花名册操作栏(问题 21)
- **目标**:操作栏新增"续签"按钮,合同即将到期时显示
- **对应需求**:问题 21
- **验收标准**
- [x] 操作栏新增"续签"按钮(Repeat 图标)
- [x] 仅合同即将到期(≤30 天)或已到期员工显示
- [x] 点击跳转 WorkProcess RENEW 并预填员工(URL 参数)
- [x] WorkProcess 支持从 URL 参数读取 `employeeId` 自动预填
- **依赖**:TASK-013(续签开始日期自动推导)、TASK-018(合同选择)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(操作栏新增按钮)
- `frontend/src/pages/WorkProcess.tsx`URL 参数预填)
- **测试要求**
- 手工验证:合同 25 天后到期 → 操作栏显示"续签"按钮 → 点击 → 跳转 RENEW → 员工已预填
### TASK-026:花名册多选增加批量转正(问题 24)
- **目标**:多选员工后支持批量转正
- **对应需求**:问题 24
- **验收标准**
- [x] 多选后新增"批量转正"按钮,仅对试用期员工生效
- [x] 弹窗统一填写转正日期(默认今天)和转正薪资
- [x] 转正薪资支持"按原薪资倍数"或"手动逐人填写"
- [x] 调用后端批量转正接口
- [x] 复用 TASK-016 的转正逻辑
- **依赖**TASK-016(单人转正)、TASK-017(薪资回写)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(批量操作栏新增按钮)
- `frontend/src/pages/roster/modals.tsx`(批量转正 Modal
- `backend/src/services/employee.service.ts`(批量转正接口)
- **测试要求**
- 手工验证:多选 3 名试用期员工 → 批量转正 → 填写统一日期+薪资 → 确认 → 3 人状态均变为正式
### TASK-027:花名册多选增加批量开具证明(问题 25)
- **目标**:多选员工后支持批量开具证明
- **对应需求**:问题 25
- **验收标准**
- [x] 多选后新增"批量开具证明"按钮
- [x] 选择证明类型(在职/收入)后批量生成
- [x] 支持批量下载(ZIP)或逐个下载
- [x] 后端新增批量生成接口
- **依赖**TASK-024(单人开具证明)
- **优先级**P2
- **阶段**:第三批
- **涉及文件**
- `frontend/src/pages/Roster.tsx`(批量操作栏新增按钮)
- `backend/src/services/work-process.service.ts`(批量生成接口)
- **测试要求**
- 手工验证:多选 3 名员工 → 批量开具在职证明 → 生成 3 份 → 可批量下载 ZIP
### TASK-028:去掉用工办理模块(问题 26)
- **目标**:侧边栏移除"用工办理"菜单,入口下沉到花名册操作栏和独立页面
- **对应需求**:问题 26
- **验收标准**
- [x] 各流程入口已下沉:转正(TASK-016)、续签(TASK-025)、变更(TASK-018)、开具证明(TASK-024)、入职(保留在花名册添加员工)
- [x] 侧边栏移除"用工办理"菜单项
- [x] WorkProcess 页面保留作为批量流程入口,迁移到"更多"分组或保留路由但不显示菜单
- [x] 已有 WorkProcess 草稿列表仍可访问(不丢数据)
- [x] 帮助文案同步更新
- **依赖**TASK-016、TASK-018、TASK-024、TASK-025 全部完成
- **优先级**P2
- **阶段**:第三批(最后执行)
- **涉及文件**
- `frontend/src/components/layout/SidebarNav.tsx`(移除菜单项)
- `frontend/src/pages/Roster.tsx`(确认所有入口已下沉)
- `frontend/src/App.tsx`(路由保留但菜单移除)
- `frontend/src/components/HelpModal.tsx`(帮助文案更新)
- **测试要求**
- 手工验证:侧边栏无"用工办理" → 花名册操作栏可完成转正/续签/变更/证明 → WorkProcess 页面仍可通过 URL 访问
---
## 第四批:P3 规划(2 项)
### TASK-029:组织架构+简单审批流(问题 27,方案 A)
- **目标**:新增组织架构模块(树形部门+岗位字典+上下级关系),支持三步以内审批流转
- **对应需求**:问题 27(方案 A:保留 position 单字段,岗位独立建表)
- **验收标准**
- [x] **组织架构子页**:系统设置新增"组织架构"
- 树形部门管理(`Department` 自引用 parent,支持增删改查、拖拽排序)
- 岗位字典(`Position`,含名称/所属部门/编制人数/职级)
- 员工 `position` 字段关联岗位字典(可选,兼容旧文本数据)
- 上下级关系(`Employee.supervisorId`
- [x] **审批流配置**
- 最多 3 步(发起人 → 直属上级 → 部门负责人)
- 按流程类型(休假/离职/调薪)配置是否启用某步
- 审批人可指定"直属上级"或"具体人员"
- [x] **现有流程接入**
- `LeaveApproval` 接入审批流引擎
- 离职/调薪等流程可选启用审批流
- [x] **数据模型**
- `Department`id/name/parentId/level/sortOrder/orgId
- `Position`id/name/departmentId/headcount/level/orgId
- `Employee` 新增 `supervisorId``departmentId`(关联 Department,兼容旧 department 字符串)
- `ApprovalFlow`id/type/steps/config/orgId
- `ApprovalInstance`id/flowId/status/currentStep/approver/result/createdAt
- **依赖**:第三批完成
- **优先级**P3
- **阶段**:第四批
- **涉及文件**
- 新增 `frontend/src/pages/OrgChart.tsx`(组织架构可视化)
- `frontend/src/pages/Settings.tsx`(新增组织架构 Tab
- `backend/prisma/schema.prisma`Department/Position/ApprovalFlow/ApprovalInstance 模型)
- 新增 `backend/src/services/approval.service.ts`(审批流引擎)
- 新增 `backend/src/routes/department.routes.ts``backend/src/routes/position.routes.ts``backend/src/routes/approval.routes.ts`
- `frontend/src/pages/LeaveApproval.tsx`(接入审批流)
- **DB 变更**:新增 4 个模型 + Employee 2 个字段,migration + 回滚方案 + seed 数据
- **测试要求**
- 手工验证:创建部门"技术部" → 创建岗位"前端工程师" → 员工关联部门和岗位 → 配置休假审批流(直属上级→部门负责人)→ 提交休假申请 → 直属上级审批 → 部门负责人审批 → 通过
### TASK-030:客服工作台增强(问题 31 增强全部 6 项)
- **目标**:在平台管理端基础上扩展客服工作台功能
- **对应需求**:问题 31 增强部分
- **验收标准**
- [x] **角色与路由**
- 新增 `SUPPORT` 角色
- 独立路由 `/support/*`,独立登录页 `/support/login`
- 独立侧边栏 `SupportSidebar`
- [x] **工单管理**
- `Ticket` 模型(标题/内容/状态/优先级/归属租户/处理人)
- `TicketMessage` 模型(工单回复)
- 工单列表、详情、回复、关闭、转派
- 客户端(企业端)可提交工单
- [x] **客户会话**
- `ChatSession` 模型(客户会话)
- 客服与客户企业管理员实时沟通或留言
- 未读消息提醒
- [x] **租户数据穿透**
- 客服选择租户后,以"只读+代操作"模式访问该租户业务数据
- 请求头带 `X-Support-Tenant-Id`,后端中间件切换租户上下文
- 可查看花名册/合同/薪税/风险等数据
- [x] **协助操作**
- 代客户重置密码(已有,复用)
- 代客户调整套餐(已有,复用)
- 代客户发起流程(新增)
- [x] **AI 辅助**
- 客服侧 AI 问答,复用现有 AI 接口
- 独立会话隔离,不影响客户企业 AI 用量
- [x] **操作日志**
- 客服操作留痕审计
- `AuditLog` 扩展支持客服角色操作记录
- **依赖**:第三批完成
- **优先级**P3
- **阶段**:第四批
- **涉及文件**
- 新增 `frontend/src/pages/support/*`SupportDashboard/Tickets/Chat/TenantData/AI/Logs
- 新增 `frontend/src/components/layout/SupportSidebar.tsx`
- `backend/prisma/schema.prisma`Ticket/TicketMessage/ChatSession 模型,User.role 新增 SUPPORT
- 新增 `backend/src/routes/support.routes.ts`
- 新增 `backend/src/middleware/support-tenant.ts`(租户上下文切换)
- `backend/src/routes/platform.routes.ts`(工单/会话接口复用)
- **DB 变更**:新增 3 个模型 + User.role 枚举新增 SUPPORTmigration + 回滚方案
- **测试要求**
- 手工验证:客服登录 → 查看工单列表 → 回复工单 → 选择租户 → 穿透查看花名册 → AI 辅助问答 → 操作日志记录
---
## 任务依赖关系图
```
第一批(P0,无依赖):
TASK-001(草稿保存)─┬─→ TASK-002(补偿金合计)─┬─→ TASK-003(驳回更新)
│ ├─→ TASK-004(违法解除风险)
│ ├─→ TASK-009(补偿月数)→ TASK-010(计算基数)
│ └─→ TASK-006(补偿金批次)
TASK-005(合同日期校验)─→ TASK-013(续签日期推导)
第二批(P1):
TASK-007(社保联动) 独立
TASK-008(合规软阻断)─→ TASK-011(交接软阻断)
TASK-012(解聘入口) 独立
TASK-014(年龄校验)─→ TASK-022(证件号码)
TASK-015(入职日期联动) 独立
TASK-016(转正入口)─┬─→ TASK-017(薪资回写)
└─→ TASK-026(批量转正)
TASK-018(合同选择) 独立
TASK-019(三期性别) 独立
第三批(P2):
TASK-020(用工关系文案) 独立
TASK-021(年假折算)← TASK-002
TASK-023(手机号查重) 独立
TASK-024(开具证明入口)─→ TASK-027(批量证明)
TASK-025(续签入口)← TASK-013, TASK-018
TASK-028(去掉用工办理)← TASK-016, TASK-018, TASK-024, TASK-025
第四批(P3):
TASK-029(组织架构+审批流) 独立
TASK-030(客服工作台) 独立
```
---
## 验收检查清单
### 每个任务完成后必做
- [x] 代码实现完成,lint 通过
- [x] 涉及前端:手工验证通过
- [x] 涉及后端:API 测试通过
- [x] 涉及 DB 变更:migration 已创建,回滚方案已准备
- [x] `run.md` 如有命令变化已同步
- [x] 本文档对应 TASK 已标记 `[x]`
### 批次完成后必做
- [x] 该批次所有 TASK 标记 `[x]`
- [x] 前端 build 通过:`cd frontend && npm run build`
- [x] 后端编译通过:`cd backend && npx tsc --noEmit`
- [x] 更新 `20260815-优化.md` 中对应问题的状态
- [x] 简洁汇报结果、风险和下一步
### 全部完成后必做
- [x] 全部 30 个 TASK 标记 `[x]`
- [x] 全域搜索确认无遗漏("身份证号"、"劳动合同"等)
- [x] DB migration 全部已应用
- [x] 前后端 build 均通过
- [x] 更新 `run.md`(如有新增模块/路由/端口)
- [x] 更新 `HelpModal.tsx` 帮助文案
- [x] Git commit(按批次提交,前缀 `feat/fix/ux/refactor`
---
## 风险与注意事项
1. **DB 变更风险**TASK-007/020/022/029/030 涉及 schema 变更,需先备份 DBmigration 需含回滚方案。
2. **兼容性风险**TASK-022(证件号码)字段名 `idCardNumber` 保持不变,仅改 label,避免历史数据丢失。
3. **依赖链风险**:TASK-028(去掉用工办理)依赖 4 个前置任务,不可提前执行。
4. **文案替换风险**:TASK-020(用工关系)需区分法定术语和面向用户文案,法律文书模板不可改。
5. **审批流复杂度**:TASK-029 是新模块,建议先做 MVP(部门树+直属上级审批),再逐步扩展。
6. **客服端复杂度**:TASK-030 是新模块,建议先做工单+数据穿透,再做会话和 AI。
7. **历史脏数据**:TASK-005(合同日期倒置)、TASK-019(男职工三期)可能存在历史脏数据,提供查询列表但不自动修复。
+340 -1
View File
@@ -20,6 +20,7 @@ enum Role {
ADMIN
HR
VIEWER
SUPPORT
}
enum EmployeeStatus {
@@ -130,8 +131,15 @@ model Organization {
city String?
contactName String?
contactPhone String?
payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批)
payrollDays Json @default("[5]") // 每月发薪日期,如 [5, 20] 表示每月5号和20号
payrollReminderDays Int @default(3) // 发薪提前提醒天数
retirementReminderEnabled Boolean @default(false) // 退休提醒开关
esignPolicyEnabled Boolean @default(false) // 规章制度电子签
esignPayslipEnabled Boolean @default(false) // 工资条电子签
esignOnboardingEnabled Boolean @default(false) // 入职文件电子签
esignTrainingEnabled Boolean @default(false) // 培训记录电子签
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -163,6 +171,7 @@ model Organization {
attendanceRecords AttendanceRecord[]
trainingRecords TrainingRecord[]
performanceRecords PerformanceRecord[]
performanceTemplates PerformanceTemplate[]
retirementPolicies RetirementPolicy[]
socialMonthlyProcesses SocialMonthlyProcess[]
evidenceChains EvidenceChain[]
@@ -183,6 +192,20 @@ model Organization {
attendancePublishes AttendancePublish[]
specialStatuses EmployeeSpecialStatus[]
companyFiles CompanyFile[]
commercialInsPlans CommercialInsurancePlan[]
commercialInsEnrollments CommercialInsuranceEnrollment[]
benefitPlans EmployeeBenefitPlan[]
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
medicalPeriodPolicies MedicalPeriodPolicy[]
// 组织架构 + 审批流
departments Department[]
positions Position[]
approvalFlows ApprovalFlow[]
approvalInstances ApprovalInstance[]
// 客服工作台
tickets Ticket[]
chatSessions ChatSession[]
}
model User {
@@ -210,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?
@@ -268,6 +294,14 @@ model Employee {
calendarEvents CalendarEvent[]
workProcesses WorkProcess[]
specialStatuses EmployeeSpecialStatus[]
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])
}
@@ -485,6 +519,21 @@ model OvertimeConfig {
updatedAt DateTime @updatedAt
}
model MedicalPeriodPolicy {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
region String // 地区名称,如"全国"、"上海"、"广东"
legalBasis String // 法律依据
rules Json // 分档规则: [{ maxYears: 5, months: 3, cycleMonths: 6 }, ...]
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, region])
@@index([orgId])
}
model NotificationLog {
id String @id @default(cuid())
orgId String
@@ -613,11 +662,14 @@ model PerformanceRecord {
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
period String // 考核周期 YYYY-MM 或 YYYY-Q1
periodType String @default("MONTHLY") // MONTHLY/QUARTERLY/YEARLY
score Float @default(0) // 考核得分
grade String @default("B") // A/B/C/D
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
summary String? // 考核评语
improvementPlan String? // 改进计划(不胜任时)
templateId String? // 关联绩效模板(选填)
dimensionScores Json? // 各维度得分明细 { dimensionName: score }
employeeAck Boolean @default(false)
ackDate DateTime?
reviewer String?
@@ -628,6 +680,22 @@ model PerformanceRecord {
@@index([orgId, employeeId])
}
model PerformanceTemplate {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 模板名称
description String? // 模板说明
dimensions Json // 考核维度 [{ name, weight, maxScore, description }]
gradeRules Json? // 等级规则 [{ grade, result, minScore, maxScore }]
isDefault Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId])
}
// ========== 员工端表 ==========
model Payslip {
@@ -664,6 +732,7 @@ model Payslip {
publishedAt DateTime? // 工资条发布到员工端的时间
publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED
scheduledAt DateTime? // 定时发送时间
viewedAt DateTime? // 员工查看工资条的时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1364,3 +1433,273 @@ model AcceptanceTest {
@@unique([orgId, verifierName])
@@index([orgId, status])
}
// ========== 商业保险 ==========
model CommercialInsurancePlan {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
type String // ACCIDENT | SUPPLEMENTARY_MEDICAL | EMPLOYER_LIABILITY | CRITICAL_ILLNESS | GROUP_LIFE | OTHER
provider String // 保险公司
policyNo String?
premium Float // 年保费
coverageAmount Float // 保额
effectiveFrom String // YYYY-MM-DD
effectiveTo String? // null = 长期
description String?
status String @default("ACTIVE") // ACTIVE | EXPIRED | CANCELLED
enrollments CommercialInsuranceEnrollment[]
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
}
model CommercialInsuranceEnrollment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
planId String
plan CommercialInsurancePlan @relation(fields: [planId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
premium Float // 个人保费
effectiveFrom String // YYYY-MM-DD
effectiveTo String?
status String @default("ACTIVE") // ACTIVE | TERMINATED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, planId])
@@index([employeeId])
}
// ========== 员工福利 ==========
model EmployeeBenefitPlan {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
category String // TRANSPORT | MEAL | HOUSING | COMMUNICATION | HEALTH_CHECK | HOLIDAY | BIRTHDAY | OTHER
amount Float // 每月金额(或每次金额)
frequency String @default("MONTHLY") // MONTHLY | QUARTERLY | YEARLY | ONE_TIME
taxDeductible Boolean @default(false) // 是否税前扣除
description String?
status String @default("ACTIVE")
enrollments EmployeeBenefitEnrollment[]
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, category])
}
model EmployeeBenefitEnrollment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
planId String
plan EmployeeBenefitPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
effectiveFrom String // YYYY-MM
effectiveTo String? // null = 至今
amount Float? // 覆盖默认金额(个别调整)
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, planId])
@@index([employeeId])
}
// ========== 电子签署(易签宝) ==========
model ESignRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
contractId String? // 关联 LaborContract
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
flowId String? // 易签宝流程ID
documentTitle String // 文件标题
documentContent String? // 文件内容(HTML/PDF base64
status String @default("PENDING") // PENDING | SIGNING | COMPLETED | REJECTED | EXPIRED | CANCELLED
signUrl String? // 签署链接
signedPdfUrl String? // 签署完成后的PDF链接
initiatedBy String // 发起人(HR用户ID
completedAt DateTime?
expiredAt DateTime?
callbackData Json? // 易签宝回调数据
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@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])
}
+111 -5
View File
@@ -49,7 +49,7 @@ interface OrgConfig {
adminName: string
contactName: string
contactPhone: string
payrollFrequency: number
payrollDays: number[]
retirementReminderEnabled: boolean
socialConfig: {
city: string
@@ -89,7 +89,7 @@ const ORGS: OrgConfig[] = [
adminName: '王建国',
contactName: '王建国',
contactPhone: '13800000010',
payrollFrequency: 1,
payrollDays: [10],
retirementReminderEnabled: true,
socialConfig: {
city: '北京',
@@ -122,7 +122,7 @@ const ORGS: OrgConfig[] = [
adminName: '李明华',
contactName: '李明华',
contactPhone: '13800000020',
payrollFrequency: 2,
payrollDays: [5, 20],
retirementReminderEnabled: true,
socialConfig: {
city: '深圳',
@@ -157,7 +157,7 @@ const ORGS: OrgConfig[] = [
adminName: '赵雪梅',
contactName: '赵雪梅',
contactPhone: '13800000030',
payrollFrequency: 1,
payrollDays: [10],
retirementReminderEnabled: false,
socialConfig: {
city: '杭州',
@@ -213,7 +213,7 @@ async function createOrg(orgConfig: OrgConfig) {
city: orgConfig.city,
contactName: orgConfig.contactName,
contactPhone: orgConfig.contactPhone,
payrollFrequency: orgConfig.payrollFrequency,
payrollDays: orgConfig.payrollDays,
retirementReminderEnabled: orgConfig.retirementReminderEnabled,
},
})
@@ -399,6 +399,112 @@ async function createOrg(orgConfig: OrgConfig) {
}
}
console.log(` ✅ 历史工资条已生成`)
// 10. 生成培训记录
console.log(` 📝 生成培训记录...`)
const trainingTopics = [
{ topic: '新员工入职培训', content: '公司文化、规章制度、安全规范', trainer: '张经理', duration: 4 },
{ topic: '岗位技能培训', content: '岗位操作规范与流程', trainer: '李主管', duration: 6 },
{ topic: '安全生产培训', content: '安全生产法规与操作规程', trainer: '王安全', duration: 3 },
{ topic: '团队协作培训', content: '沟通技巧与团队建设', trainer: '刘讲师', duration: 2 },
]
for (let i = 0; i < allEmployees.length; i++) {
const emp = allEmployees[i]
const t = trainingTopics[i % trainingTopics.length]
const trainDate = new Date(2026, (i % 6), 15)
const ackStatus = i % 3 === 0 ? 'PENDING' : i % 3 === 1 ? 'SIGNED' : 'REFUSED'
await prisma.trainingRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
trainingDate: trainDate,
topic: t.topic,
content: t.content,
trainer: t.trainer,
duration: t.duration,
ackStatus: ackStatus as any,
ackDate: ackStatus === 'SIGNED' ? new Date(trainDate.getTime() + 86400000) : null,
createdBy: admin.id,
},
})
}
console.log(` ✅ 培训记录已生成 (${allEmployees.length}条)`)
// 11. 生成绩效记录
console.log(` 📊 生成绩效记录...`)
const perfResults = ['EXCELLENT', 'QUALIFIED', 'QUALIFIED', 'NEED_IMPROVE', 'UNQUALIFIED'] as const
const perfGrades = ['A', 'B', 'B', 'C', 'D']
for (let i = 0; i < allEmployees.length; i++) {
const emp = allEmployees[i]
const idx = i % perfResults.length
const score = 95 - idx * 12
await prisma.performanceRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
period: '2026-Q1',
score,
grade: perfGrades[idx],
result: perfResults[idx] as any,
summary: idx < 2 ? '工作表现优秀,完成任务质量高' : idx < 4 ? '基本完成工作目标,有待提升' : '未达到岗位要求,需制定改进计划',
improvementPlan: idx >= 3 ? '加强技能培训,设定阶段性目标' : null,
reviewer: admin.name,
employeeAck: i % 2 === 0,
createdBy: admin.id,
},
})
// 部分员工有Q2绩效
if (i % 2 === 0) {
await prisma.performanceRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
period: '2026-Q2',
score: score - 5,
grade: perfGrades[Math.min(idx + 1, 4)],
result: perfResults[Math.min(idx + 1, 4)] as any,
summary: '二季度绩效评估',
reviewer: admin.name,
employeeAck: false,
createdBy: admin.id,
},
})
}
}
console.log(` ✅ 绩效记录已生成 (${allEmployees.length}条)`)
// 12. 生成违纪记录(部分员工)
console.log(` ⚠️ 生成违纪记录...`)
const discTypes = ['LATE', 'ABSENT', 'INSUBORDINATION', 'MISCONDUCT'] as const
const discDescriptions = [
'月内累计迟到3次,超过公司允许范围',
'未经请假擅自缺勤1天',
'不服从主管工作安排,拒绝执行合理指令',
'违反公司安全操作规程,未佩戴防护设备',
]
const discActions = ['ORAL_WARNING', 'WRITTEN_WARNING', 'DEDUCTION', 'WRITTEN_WARNING'] as const
for (let i = 0; i < Math.min(allEmployees.length, 4); i++) {
const emp = allEmployees[i]
const violationDate = new Date(2026, i % 6, 10)
await prisma.disciplinaryRecord.create({
data: {
orgId: org.id,
employeeId: emp.id,
violationDate,
violationType: discTypes[i],
description: discDescriptions[i],
severity: i < 2 ? 'WARNING' : 'SERIOUS',
action: discActions[i],
actionDetail: i === 2 ? '扣除当日工资' : '',
employeeAck: i % 2 === 0,
ackDate: i % 2 === 0 ? new Date(violationDate.getTime() + 86400000) : null,
ackMethod: i % 2 === 0 ? 'SIGN' : null,
witness: i >= 2 ? '部门主管' : null,
createdBy: admin.id,
},
})
}
console.log(` ✅ 违纪记录已生成 (${Math.min(allEmployees.length, 4)}条)`)
}
// ========== 主函数 ==========
+1 -1
View File
@@ -78,7 +78,7 @@ async function main() {
plan: 'PRO',
maxEmployees: 50,
city: '上海',
payrollFrequency: 1,
payrollDays: [10],
},
})
console.log('企业已创建:', org.name)
+16 -1
View File
@@ -23,8 +23,9 @@ app.use(compression({
}))
app.use(
cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
origin: true,
credentials: true,
exposedHeaders: ['Content-Disposition'],
}),
)
app.use(express.json({ limit: '10mb' }))
@@ -60,12 +61,19 @@ 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'
import acceptanceTestRoutes from './routes/acceptance-test.routes'
import leaveRoutes from './routes/leave.routes'
import salaryRoutes from './routes/salary.routes'
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
import benefitRoutes from './routes/benefit.routes'
import esignRoutes from './routes/esign.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -89,12 +97,19 @@ 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)
app.use('/api/v1/acceptance-tests', acceptanceTestRoutes)
app.use('/api/v1/leaves', leaveRoutes)
app.use('/api/v1/salary', salaryRoutes)
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
app.use('/api/v1/benefits', benefitRoutes)
app.use('/api/v1/esign', esignRoutes)
app.use(errorHandler)
+7 -2
View File
@@ -8,10 +8,15 @@ export interface AuthRequest extends Request {
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
let token: string | undefined
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7)
} else if (typeof req.query.token === 'string') {
token = req.query.token
}
if (!token) {
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } })
}
const token = authHeader.substring(7)
const payload = verifyAccessToken(token)
if (!payload) {
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
+107
View File
@@ -0,0 +1,107 @@
/**
* 审批流路由
* 提供审批流配置和实例管理
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
import { processApproval, cancelApproval } from '../services/approval.service'
const router = Router()
const flowSchema = z.object({
type: z.string(), // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
name: z.string().min(1),
enabled: z.boolean().default(true),
steps: z.array(z.object({
step: z.number().int().min(1).max(3),
approverType: z.enum(['SUPERVISOR', 'DEPT_HEAD', 'PERSON']),
approverId: z.string().optional(),
name: z.string(),
})).min(1, '至少一个审批步骤').max(3, '最多三个审批步骤'),
})
/** 获取审批流配置列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const flows = await prisma.approvalFlow.findMany({
where: { orgId: req.user!.orgId! },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: flows })
} catch (err) {
next(err)
}
})
/** 创建/更新审批流配置(upsert by type */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = flowSchema.parse(req.body)
const existing = await prisma.approvalFlow.findFirst({
where: { orgId: req.user!.orgId!, type: data.type },
})
let flow
if (existing) {
flow = await prisma.approvalFlow.update({
where: { id: existing.id },
data: { ...data, createdBy: req.user!.id },
})
} else {
flow = await prisma.approvalFlow.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
}
res.json({ success: true, data: flow })
} catch (err) {
next(err)
}
})
/** 获取待我审批的实例 */
router.get('/pending', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const instances = await prisma.approvalInstance.findMany({
where: { orgId: req.user!.orgId!, status: 'PENDING' },
orderBy: { createdAt: 'desc' },
include: { employee: { select: { id: true, name: true, department: true } } },
})
res.json({ success: true, data: instances })
} catch (err) {
next(err)
}
})
/** 处理审批 */
router.post('/:id/process', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { result, comment } = req.body as { result: 'APPROVED' | 'REJECTED'; comment?: string }
if (!result || !['APPROVED', 'REJECTED'].includes(result)) {
throw { code: 'VALIDATION_ERROR', message: 'result 必须为 APPROVED 或 REJECTED' }
}
const approverName = req.user!.id || '审批人'
const outcome = await processApproval(req.user!.orgId!, id, req.user!.id, approverName, result, comment)
res.json({ success: true, data: outcome })
} catch (err) {
next(err)
}
})
/** 取消审批 */
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await cancelApproval(req.user!.orgId!, id)
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+17
View File
@@ -18,6 +18,7 @@ import {
getLeaveRecords,
createLeaveRecord,
deleteLeaveRecord,
manualCorrectAttendance,
} from '../services/attendance.service'
import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
@@ -206,6 +207,22 @@ router.delete('/shift-assignments/:id', authMiddleware, async (req: AuthRequest,
// ========== 每日出勤 ==========
router.post('/manual-correct', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
employeeId: z.string(),
date: z.string(),
checkInTime: z.string().optional(),
checkOutTime: z.string().optional(),
status: z.string().optional(),
remark: z.string().optional(),
})
const data = schema.parse(req.body)
const record = await manualCorrectAttendance(req.user!.orgId, { ...data, createdBy: req.user!.id })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
router.get('/daily', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const date = req.query.date as string
+173
View File
@@ -0,0 +1,173 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const planSchema = z.object({
name: z.string().min(1),
category: z.string(),
amount: z.number().default(0),
frequency: z.string().default('MONTHLY'),
taxDeductible: z.boolean().default(false),
description: z.string().optional(),
})
const BENEFIT_CATEGORIES: Record<string, string> = {
TRANSPORT: '交通补贴',
MEAL: '餐补',
HOUSING: '住房补贴',
COMMUNICATION: '通讯补贴',
HEALTH_CHECK: '体检',
HOLIDAY: '节日福利',
BIRTHDAY: '生日福利',
OTHER: '其他',
}
// 福利方案列表
router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const plans = await prisma.employeeBenefitPlan.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } },
})
res.json({ success: true, data: plans })
} catch (err) { next(err) }
})
// 创建福利方案
router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.parse(req.body)
const plan = await prisma.employeeBenefitPlan.create({
data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 更新福利方案
router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.partial().parse(req.body)
const plan = await prisma.employeeBenefitPlan.update({
where: { id: req.params.planId, orgId: req.user!.orgId },
data,
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 删除福利方案
router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.employeeBenefitPlan.delete({
where: { id: req.params.planId, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) { next(err) }
})
// 方案参保人员
router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.employeeBenefitEnrollment.findMany({
where: { orgId: req.user!.orgId, planId: req.params.planId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
const data = enrollments.map((e: any) => ({
id: e.id,
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
amount: e.amount,
status: e.status,
}))
res.json({ success: true, data })
} catch (err) { next(err) }
})
// 批量参保
router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeIds, effectiveFrom } = req.body as { employeeIds: string[]; effectiveFrom: string }
const plan = await prisma.employeeBenefitPlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } })
if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } })
const existing = await prisma.employeeBenefitEnrollment.findMany({
where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' },
select: { employeeId: true },
})
const existingIds = new Set(existing.map((e: any) => e.employeeId))
const newIds = employeeIds.filter((id) => !existingIds.has(id))
if (newIds.length > 0) {
await prisma.employeeBenefitEnrollment.createMany({
data: newIds.map((empId) => ({
orgId: req.user!.orgId,
planId: plan.id,
employeeId: empId,
effectiveFrom: effectiveFrom || new Date().toISOString().slice(0, 7),
})),
})
}
res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } })
} catch (err) { next(err) }
})
// 退福利
router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { effectiveTo } = req.body as { effectiveTo: string }
const enrollment = await prisma.employeeBenefitEnrollment.update({
where: { id: req.params.enrollmentId, orgId: req.user!.orgId },
data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 7) },
})
res.json({ success: true, data: enrollment })
} catch (err) { next(err) }
})
// 员工福利汇总(按员工维度)
router.get('/employee-summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.employeeBenefitEnrollment.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
employee: { select: { id: true, name: true, department: true } },
plan: { select: { id: true, name: true, category: true, amount: true, frequency: true } },
},
})
const summary: Record<string, any> = {}
for (const e of enrollments) {
if (!summary[e.employeeId]) {
summary[e.employeeId] = {
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
benefits: [],
totalMonthly: 0,
}
}
const amount = e.amount ?? e.plan.amount
summary[e.employeeId].benefits.push({
planId: e.planId,
planName: e.plan.name,
category: e.plan.category,
categoryLabel: BENEFIT_CATEGORIES[e.plan.category] || e.plan.category,
amount,
})
if (e.plan.frequency === 'MONTHLY') {
summary[e.employeeId].totalMonthly += amount
}
}
res.json({ success: true, data: Object.values(summary) })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,171 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const planSchema = z.object({
name: z.string().min(1),
type: z.string(),
provider: z.string().min(1),
policyNo: z.string().optional(),
premium: z.number().default(0),
coverageAmount: z.number().default(0),
effectiveFrom: z.string(),
effectiveTo: z.string().optional(),
description: z.string().optional(),
})
// 方案列表
router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const plans = await prisma.commercialInsurancePlan.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } },
})
res.json({ success: true, data: plans })
} catch (err) { next(err) }
})
// 方案详情(含参保人员)
router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.commercialInsuranceEnrollment.findMany({
where: { orgId: req.user!.orgId, planId: req.params.planId },
include: {
employee: { select: { id: true, name: true, department: true, idCardNumber: true } },
},
orderBy: { createdAt: 'desc' },
})
const data = enrollments.map((e: any) => ({
id: e.id,
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
idCardMasked: e.employee.idCardNumber ? e.employee.idCardNumber.slice(0, 3) + '****' + e.employee.idCardNumber.slice(-4) : null,
premium: e.premium,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
status: e.status,
}))
res.json({ success: true, data })
} catch (err) { next(err) }
})
// 创建方案
router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.parse(req.body)
const plan = await prisma.commercialInsurancePlan.create({
data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 更新方案
router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.partial().parse(req.body)
const plan = await prisma.commercialInsurancePlan.update({
where: { id: req.params.planId, orgId: req.user!.orgId },
data,
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 删除方案
router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.commercialInsurancePlan.delete({
where: { id: req.params.planId, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) { next(err) }
})
// 批量参保
router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeIds, premium, effectiveFrom } = req.body as { employeeIds: string[]; premium: number; effectiveFrom: string }
const plan = await prisma.commercialInsurancePlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } })
if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } })
const existing = await prisma.commercialInsuranceEnrollment.findMany({
where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' },
select: { employeeId: true },
})
const existingIds = new Set(existing.map((e: any) => e.employeeId))
const newIds = employeeIds.filter((id) => !existingIds.has(id))
if (newIds.length > 0) {
await prisma.commercialInsuranceEnrollment.createMany({
data: newIds.map((empId) => ({
orgId: req.user!.orgId,
planId: plan.id,
employeeId: empId,
premium: premium || plan.premium,
effectiveFrom: effectiveFrom || plan.effectiveFrom,
})),
})
}
res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } })
} catch (err) { next(err) }
})
// 退保
router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { effectiveTo } = req.body as { effectiveTo: string }
const enrollment = await prisma.commercialInsuranceEnrollment.update({
where: { id: req.params.enrollmentId, orgId: req.user!.orgId },
data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 10) },
})
res.json({ success: true, data: enrollment })
} catch (err) { next(err) }
})
// 员工商险汇总(按员工维度)
router.get('/employee-summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.commercialInsuranceEnrollment.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
employee: { select: { id: true, name: true, department: true } },
plan: { select: { id: true, name: true, type: true, provider: true, coverageAmount: true } },
},
})
const summary: Record<string, any> = {}
for (const e of enrollments) {
if (!summary[e.employeeId]) {
summary[e.employeeId] = {
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
insurances: [],
totalPremium: 0,
totalCoverage: 0,
}
}
summary[e.employeeId].insurances.push({
planId: e.planId,
planName: e.plan.name,
type: e.plan.type,
provider: e.plan.provider,
premium: e.premium,
coverageAmount: e.plan.coverageAmount,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
})
summary[e.employeeId].totalPremium += e.premium
summary[e.employeeId].totalCoverage += e.plan.coverageAmount
}
res.json({ success: true, data: Object.values(summary) })
} catch (err) { next(err) }
})
export default router
+42
View File
@@ -285,6 +285,43 @@ router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, r
take: 10,
})
// 5. 发薪日提前提醒
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { payrollDays: true, payrollReminderDays: true },
})
const payrollDays = Array.isArray(org?.payrollDays) ? org.payrollDays as number[] : []
const reminderDays = org?.payrollReminderDays ?? 3
const payrollReminderItems: any[] = []
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-indexed
for (const day of payrollDays) {
// 本月发薪日
const thisMonthPayday = new Date(currentYear, currentMonth, day)
const diffDays = Math.floor((thisMonthPayday.getTime() - now.getTime()) / 86400000)
if (diffDays >= 0 && diffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 1}-${day}`,
title: `发薪日(每月${day}号)${diffDays === 0 ? '今天' : `${diffDays}天后`}`,
subtitle: diffDays === 0 ? '今天发薪' : `还有${diffDays}`,
link: '/money',
})
}
// 下月发薪日(如果当月已过,看下月)
if (diffDays < 0) {
const nextMonthPayday = new Date(currentYear, currentMonth + 1, day)
const nextDiffDays = Math.floor((nextMonthPayday.getTime() - now.getTime()) / 86400000)
if (nextDiffDays >= 0 && nextDiffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 2}-${day}`,
title: `发薪日(下月${day}号)${nextDiffDays === 0 ? '今天' : `${nextDiffDays}天后`}`,
subtitle: `还有${nextDiffDays}`,
link: '/money',
})
}
}
}
// 按优先级分组
const actions: Array<{ category: string; priority: 'high' | 'medium' | 'low'; items: any[] }> = [
{
@@ -333,6 +370,11 @@ router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, r
link: '/special-status',
})),
},
{
category: '发薪提醒',
priority: 'medium',
items: payrollReminderItems,
},
]
// 过滤空分类
+101
View File
@@ -0,0 +1,101 @@
/**
* 部门管理路由
* 提供部门的增删改查(树形结构)
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const createDeptSchema = z.object({
name: z.string().min(1, '部门名称必填'),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().default(0),
description: z.string().max(200).optional(),
})
const updateDeptSchema = createDeptSchema.partial()
/** 获取部门树 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const departments = await prisma.department.findMany({
where: { orgId: req.user!.orgId! },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
include: { _count: { select: { employees: true, positions: true } } },
})
res.json({ success: true, data: departments })
} catch (err) {
next(err)
}
})
/** 创建部门 */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createDeptSchema.parse(req.body)
let level = 0
if (data.parentId) {
const parent = await prisma.department.findFirst({ where: { id: data.parentId, orgId: req.user!.orgId! } })
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
level = parent.level + 1
}
const dept = await prisma.department.create({
data: {
...data,
level,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: dept })
} catch (err) {
next(err)
}
})
/** 更新部门 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = updateDeptSchema.parse(req.body)
// 防止循环引用
if (data.parentId === id) throw { code: 'VALIDATION_ERROR', message: '不能将自身设为父部门' }
let level: number | undefined
if (data.parentId) {
const parent = await prisma.department.findFirst({ where: { id: data.parentId, orgId: req.user!.orgId! } })
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
level = parent.level + 1
} else if (data.parentId === null) {
level = 0
}
const dept = await prisma.department.update({
where: { id },
data: { ...data, level },
})
res.json({ success: true, data: dept })
} catch (err) {
next(err)
}
})
/** 删除部门 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
// 检查是否有子部门
const children = await prisma.department.findFirst({ where: { parentId: id, orgId: req.user!.orgId! } })
if (children) throw { code: 'VALIDATION_ERROR', message: '请先删除子部门' }
// 检查是否有关联员工
const employees = await prisma.employee.findFirst({ where: { departmentId: id, orgId: req.user!.orgId! } })
if (employees) throw { code: 'VALIDATION_ERROR', message: '该部门下仍有员工,无法删除' }
await prisma.department.delete({ where: { id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+67 -3
View File
@@ -3,6 +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, decrypt } from '../lib/crypto'
import {
createEmployeeSchema,
updateEmployeeSchema,
@@ -79,7 +80,7 @@ router.get('/list', authMiddleware, async (req: AuthRequest, res, next) => {
status: { in: status },
...(department && { department }),
},
select: { id: true, name: true, department: true, position: true, phone: true, status: true },
select: { id: true, name: true, department: true, position: true, phone: true, gender: true, status: true },
orderBy: { name: 'asc' },
})
res.json({ success: true, data: employees })
@@ -97,6 +98,45 @@ router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// 身份证查重
router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const idCard = req.query.idCard as string
if (!idCard || idCard.length < 18) {
return res.json({ success: true, data: { exists: false } })
}
const hash = sha256(idCard)
const employee = await prisma.employee.findFirst({
where: { orgId: req.user!.orgId, idCardHash: hash },
select: { id: true, name: true, department: true, status: true },
})
res.json({ success: true, data: { exists: !!employee, employee } })
} catch (err) {
next(err)
}
})
// 手机号查重
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)
@@ -284,9 +324,33 @@ router.delete('/contracts/:contractId', authMiddleware, async (req: AuthRequest,
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
await prisma.laborContract.delete({ where: { id: req.params.contractId } })
// 作废处理:设置结束日期为当前时间,保留记录但不物理删除
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { endDate: new Date() },
})
const emp = await prisma.employee.findFirst({ where: { id: contract.employeeId }, select: { name: true } })
await auditLog(req, 'DELETE_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate })
await auditLog(req, 'VOID_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 补充上传合同附件
router.patch('/contracts/:contractId/attachment', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const contract = await prisma.laborContract.findFirst({
where: { id: req.params.contractId, orgId: req.user!.orgId },
})
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
const { attachmentUrl } = req.body as { attachmentUrl: string }
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { attachmentUrl: attachmentUrl || null },
})
res.json({ success: true })
} catch (err) {
next(err)
@@ -146,10 +146,63 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx
let content = template.content
const variables: Record<string, string> = {}
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string' && key !== 'token') variables[key] = value
}
if (Object.keys(variables).length > 0) {
for (const [key, value] of Object.entries(variables)) {
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
}
// 将纯文本转换为HTML段落,使Word样式生效
const textToHtml = (text: string): string => {
// 如果内容已包含HTML标签,直接返回
if (/<[a-z][\s\S]*>/i.test(text)) return text
const lines = text.split(/\n/)
let html = ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
html += '<p style="text-indent:0">&nbsp;</p>'
continue
}
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
html += `<h3>${trimmed}</h3>`
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
html += `<h1>${trimmed}</h1>`
} else if (/^(甲方|乙方)(盖章|签字)/.test(trimmed) || /^日期[:]/.test(trimmed)) {
html += `<p class="sign">${trimmed}</p>`
} else {
html += `<p>${trimmed}</p>`
}
}
return html
}
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>${template.name}</title>
<!--[if gte mso 9]><xml>
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
</xml><![endif]-->
<style>
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
p { text-indent: 2em; margin: 0 0 10pt 0; }
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
th { background: #f0f0f0; font-weight: bold; text-align: center; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
</style></head>
<body>${textToHtml(content)}</body></html>`
const encoded = encodeURIComponent(template.name + '.doc')
res.setHeader('Content-Type', 'application/msword')
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(template.content)
res.send(htmlContent)
} catch (err) {
next(err)
}
+153
View File
@@ -0,0 +1,153 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const createSignSchema = z.object({
contractId: z.string().optional(),
employeeId: z.string().min(1),
documentTitle: z.string().min(1),
documentContent: z.string().optional(),
remark: z.string().optional(),
scene: z.string().optional(),
})
// 签署记录列表
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const status = req.query.status as string | undefined
const scene = req.query.scene as string | undefined
const records = await prisma.eSignRecord.findMany({
where: {
orgId: req.user!.orgId,
...(status && { status }),
...(scene && { scene }),
},
include: {
employee: { select: { id: true, name: true, department: true, phone: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 发起签署
// TODO: 对接易签宝 API — 此处为框架预留
// 1. 调用易签宝创建签署流程
// 2. 获取签署链接
// 3. 保存 flowId 和 signUrl
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createSignSchema.parse(req.body)
// 预留:调用易签宝 API 创建签署流程
// const esignResult = await esignApi.createFlow({
// title: data.documentTitle,
// signerPhone: employee.phone,
// signerName: employee.name,
// content: data.documentContent,
// })
// const flowId = esignResult.flowId
// const signUrl = esignResult.signUrl
const record = await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
contractId: data.contractId || null,
employeeId: data.employeeId,
scene: data.scene || 'CONTRACT',
documentTitle: data.documentTitle,
documentContent: data.documentContent || null,
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: data.remark || null,
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期
},
})
res.json({
success: true,
data: record,
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。',
})
} catch (err) { next(err) }
})
// 查询签署状态
// TODO: 对接易签宝 API — 查询流程状态并同步
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
// 预留:调用易签宝 API 查询流程状态
// const esignStatus = await esignApi.getFlowStatus(record.flowId)
// if (esignStatus !== record.status) {
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } })
// }
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 易签宝回调接口(无需认证)
router.post('/callback', async (req, res: Response, next: NextFunction) => {
try {
const { flowId, status, signedPdfUrl, ...rest } = req.body
// TODO: 验证易签宝回调签名
// if (!verifyEsignCallback(req.headers, req.body)) {
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
// }
if (flowId) {
const record = await prisma.eSignRecord.findFirst({ where: { flowId } })
if (record) {
await prisma.eSignRecord.update({
where: { id: record.id },
data: {
status: status || 'COMPLETED',
signedPdfUrl: signedPdfUrl || null,
completedAt: status === 'COMPLETED' ? new Date() : null,
callbackData: rest as any,
},
})
// 如果关联了合同,更新合同的签署方式和电子合同URL
if (record.contractId && status === 'COMPLETED') {
await prisma.laborContract.update({
where: { id: record.contractId },
data: {
signMethod: 'ELECTRONIC',
electronicContractUrl: signedPdfUrl || null,
},
})
}
}
}
res.json({ success: true })
} catch (err) { next(err) }
})
// 取消签署
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.eSignRecord.update({
where: { id: req.params.id, orgId: req.user!.orgId },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
export default router
+9 -2
View File
@@ -287,7 +287,7 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
{ header: '状态', key: 'status', width: 8 },
{ header: '入职日期', key: 'hireDate', width: 12 },
{ header: '手机号', key: 'phone', width: 13 },
{ header: '身份证号', key: 'idCardNumber', width: 20 },
{ header: '证件号码', key: 'idCardNumber', width: 20 },
{ header: '月工资', key: 'monthlySalary', width: 10 },
{ header: '社保基数', key: 'socialInsBase', width: 10 },
{ header: '公积金基数', key: 'housingFundBase', width: 10 },
@@ -359,9 +359,16 @@ router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Respon
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const search = req.query.search as string | undefined
const dateFrom = req.query.dateFrom as string | undefined
const dateTo = req.query.dateTo as string | undefined
const where: any = { orgId }
if (status) where.status = status
if (dateFrom || dateTo) {
where.terminationDate = {}
if (dateFrom) where.terminationDate.gte = new Date(dateFrom)
if (dateTo) where.terminationDate.lte = new Date(dateTo + 'T23:59:59')
}
if (department || search) {
where.employee = {}
if (department) where.employee.department = department
@@ -471,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 || '' }
+190 -88
View File
@@ -18,17 +18,17 @@ function contentDisposition(filename: string): string {
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
// 身份证号格式校验(18位正则 + 校验位算法)
// 证件号码格式校验(18位正则 + 校验位算法)
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
if (!idCard) return { valid: true }
const s = idCard.trim()
// 15位身份证号升级为18位
// 15位证件号码升级为18位
if (/^\d{15}$/.test(s)) {
const upgraded = upgrade15To18(s)
return { valid: true, upgraded }
}
if (!/^\d{17}[\dXx]$/.test(s)) {
return { valid: false, error: '身份证号格式错误(应为18位)' }
return { valid: false, error: '证件号码格式错误(应为18位)' }
}
// 校验位算法
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
@@ -36,7 +36,7 @@ function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; er
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
const expected = checkCodes[sum % 11]
if (s.charAt(17).toUpperCase() !== expected) {
return { valid: false, error: '身份证号校验位错误' }
return { valid: false, error: '证件号码校验位错误' }
}
return { valid: true }
}
@@ -124,7 +124,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '证件号码')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(getField(r, '入职日期'))
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
@@ -144,8 +144,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(contractSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const sd = parseDate(getField(r, '合同开始日期'))
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
@@ -159,8 +159,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const otType = val(getField(r, '加班类型')) || '工作日加班'
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
@@ -173,8 +173,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
preview.disciplinary.push(row)
}
@@ -185,8 +185,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
const dt = parseDate(getField(r, '日期'))
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
@@ -259,8 +259,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const salary = String(num(getField(r, '月工资')))
if (salary === '0') { result.skipped++; result.errors.push(`员工第${i + 2}行:月工资为空`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '月工资为空' }); continue }
let idCard = val(getField(r, '身份证号'))
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:身份证号为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '身份证号为空' }); continue }
let idCard = val(getField(r, '证件号码'))
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:证件号码为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码为空' }); continue }
if (idCard) {
const idCheck = validateIdCard(idCard)
if (!idCheck.valid) { result.skipped++; result.errors.push(`员工第${i + 2}行:${idCheck.error}`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: idCheck.error }); continue }
@@ -329,8 +329,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
const msg = e?.message || ''
if (msg.includes('Unique constraint')) {
result.duplicates++
result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '身份证号重复' })
result.errors.push(`员工第${i + 2}行:该员工已存在(证件号码重复),跳过`)
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '证件号码重复' })
} else if (msg.includes('invalid') || msg.includes('validation')) {
result.skipped++
result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
@@ -353,7 +353,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const startDate = parseDate(getField(r, '合同开始日期'))
@@ -399,7 +399,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
@@ -429,7 +429,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
@@ -462,7 +462,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(getField(r, '身份证号'))
const idCard = val(getField(r, '证件号码'))
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
@@ -495,30 +495,30 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '证件号码': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '身份证号', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '证件号码', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
// 设置示例行样式(灰色背景)
empWs['!cols'] = [{ wch: 10 }, { wch: 12 }, { wch: 22 }, { wch: 13 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 6 }, { wch: 6 }, { wch: 6 }]
XLSX.utils.book_append_sheet(wb, empWs, '员工信息')
const contractData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
{ '姓名*': '张三', '证件号码': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
@@ -541,14 +541,24 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const result: any = { month, attendance: 0, overtime: 0, discipline: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '违纪记录': '追加(同员工同日可多条)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
const empByName = new Map(employees.map(e => [e.name, e]))
// 获取加班费配置,用于自动计算 totalPay
const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
function calcOvertimePay(monthlyWage: number, wdHours: number, weHours: number, hoHours: number) {
const hourlyWage = (monthlyWage || 0) / otConfig.monthlyDays / otConfig.dailyHours
const weekdayPay = hourlyWage * otConfig.weekdayRate * wdHours
const weekendPay = hourlyWage * otConfig.weekendRate * weHours
const holidayPay = hourlyWage * otConfig.holidayRate * hoHours
return Math.round((weekdayPay + weekendPay + holidayPay) * 100) / 100
}
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
@@ -556,56 +566,109 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
return empByName.get(val(getField(r, '姓名')))
}
// 考勤记录
// 考勤记录 + 加班记录(支持合并Sheet"考勤与加班"或独立Sheet
const mergedSheet = wb.Sheets['考勤与加班']
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 加班记录
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
if (mergedSheet) {
// 合并Sheet:每行同时处理考勤和加班
const rows = XLSX.utils.sheet_to_json(mergedSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
if (!emp) { result.errors.push(`${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`加班${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(getField(r, '加班时长'))
const otType = val(getField(r, '加班类型')) || '工作日加班'
const wdHours = num(getField(r, '工作日加班时')) || (otType.includes('工作日') ? hours : 0)
const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
if (!date) { result.errors.push(`${i + 2}行:日期格式错误`); continue }
// 考勤部分
const attStatus = val(getField(r, '考勤状态'))
if (attStatus || val(getField(r, '班时')) || val(getField(r, '下班时间'))) {
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
update: { status: statusMap[attStatus] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
})
result.attendance++
}
// 加班部分
const wdHours = num(getField(r, '工作日加班时长'))
const weHours = num(getField(r, '休息日加班时长'))
const hoHours = num(getField(r, '法定节假日加班时长'))
if (wdHours > 0 || weHours > 0 || hoHours > 0) {
const otMonth = dateToMonth(date)
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
totalPay: { increment: totalPay },
},
})
result.overtime++
}
} catch (e: any) { result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`) }
}
} else {
// 向后兼容:独立Sheet
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null, createdBy: userId },
update: { status: statusMap[val(getField(r, '考勤状态'))] || 'NORMAL', checkInTime: val(getField(r, '上班时间')) || null, checkOutTime: val(getField(r, '下班时间')) || null, remark: val(getField(r, '备注')) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(getField(r, '加班时长'))
const otType = val(getField(r, '加班类型')) || '工作日加班'
const wdHours = num(getField(r, '工作日加班时长')) || (otType.includes('工作日') ? hours : 0)
const weHours = num(getField(r, '休息日加班时长')) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(getField(r, '法定节假日加班时长')) || (otType.includes('法定') ? hours : 0)
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) || 0 } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const totalPay = calcOvertimePay(monthlyWage, wdHours, weHours, hoHours)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours, totalPay } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
totalPay: { increment: totalPay },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
}
@@ -685,6 +748,27 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
}
}
// 违纪记录
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}`); continue }
const date = parseDate(getField(r, '日期'))
if (!date) { result.errors.push(`违纪第${i + 2}行:日期格式错误`); continue }
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({
data: { orgId, employeeId: emp.id, violationDate: date, violationType: typeMap[val(getField(r, '违纪类型'))] || 'OTHER', description: val(getField(r, '描述')) || '', action: actMap[val(getField(r, '处罚'))] || 'ORAL_WARNING', createdBy: userId },
})
result.discipline++
} catch (e: any) { result.errors.push(`违纪第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
@@ -694,24 +778,41 @@ 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()
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
// 合并考勤+加班为一个Sheet,减少重复录入姓名证件号码
const attOtData = [{
'姓名': '张三',
'证件号码': '110101199001011234',
'日期': '2024-06-01',
'考勤状态': '正常',
'上班时间': '09:00',
'下班时间': '18:00',
'工作日加班时长': 0,
'休息日加班时长': 0,
'法定节假日加班时长': 0,
'备注': '',
}]
const attOtWs = XLSX.utils.json_to_sheet(attOtData)
attOtWs['!cols'] = [
{ wch: 10 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 8 }, { wch: 8 },
{ wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 12 },
]
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
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', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition('月度增减员导入模板.xlsx'))
res.setHeader('Content-Disposition', contentDisposition('考勤月度导入模板.xlsx'))
res.send(buf)
})
@@ -746,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)
@@ -762,7 +863,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
// 重新计算税费
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...calcResult } })
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
result.updated++
} catch (e: any) {
result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`)
@@ -804,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' })
@@ -819,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' })
@@ -851,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 }
+82
View File
@@ -129,6 +129,88 @@ router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFu
}
})
// 从考勤记录同步加班工时
router.post('/overtime/sync-from-attendance', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { month } = req.body as { month: string }
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, message: '请提供有效的月份(YYYY-MM' })
}
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart)
monthEnd.setMonth(monthEnd.getMonth() + 1)
// 获取该月所有考勤记录(含加班工时)
const records = await prisma.attendanceRecord.findMany({
where: { orgId, date: { gte: monthStart, lt: monthEnd }, overtimeHours: { gt: 0 } },
})
if (records.length === 0) {
return res.json({ success: false, message: '该月考勤记录中无加班工时' })
}
// 按员工汇总加班工时,按日期类型分类
const empMap = new Map<string, { weekday: number; weekend: number; holiday: number }>()
for (const r of records) {
const day = new Date(r.date)
const dayOfWeek = day.getDay() // 0=周日, 6=周六
let type: 'weekday' | 'weekend' | 'holiday' = 'weekday'
if (dayOfWeek === 0 || dayOfWeek === 6) {
type = 'weekend'
}
// 简单判断法定节假日:这里使用周末判断,实际法定节假日需要额外配置
// 如果有 holidayHours 字段在 attendanceRecord 中,优先使用
if (!empMap.has(r.employeeId)) {
empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 })
}
const entry = empMap.get(r.employeeId)!
entry[type] += r.overtimeHours || 0
}
// 获取员工月工资用于计算加班费
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
let synced = 0
for (const [employeeId, hours] of empMap) {
const emp = await prisma.employee.findFirst({ where: { id: employeeId }, select: { monthlySalary: true } })
let monthlyWage = 0
try { monthlyWage = emp?.monthlySalary ? Number(decrypt(emp.monthlySalary)) : 0 } catch { monthlyWage = Number(emp?.monthlySalary) || 0 }
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * hours.weekday
const weekendPay = hourlyWage * config.weekendRate * hours.weekend
const holidayPay = hourlyWage * config.holidayRate * hours.holiday
const totalPay = weekdayPay + weekendPay + holidayPay
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId, month } },
update: {
weekdayHours: hours.weekday,
weekendHours: hours.weekend,
holidayHours: hours.holiday,
weekdayPay, weekendPay, holidayPay, totalPay,
},
create: {
orgId, employeeId, month,
weekdayHours: hours.weekday,
weekendHours: hours.weekend,
holidayHours: hours.holiday,
weekdayPay, weekendPay, holidayPay, totalPay,
},
})
synced++
}
res.json({ success: true, data: { synced, totalEmployees: empMap.size } })
} catch (err) {
next(err)
}
})
// ========== 工资条管理 ==========
const payslipSchema = z.object({
+47 -12
View File
@@ -150,7 +150,7 @@ router.get('/batches/archived/list', async (req: AuthRequest, res: Response, nex
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, monthFrom, monthTo, status, type } = req.query
const { month, monthFrom, monthTo, status, type, dateFrom, dateTo } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
@@ -159,8 +159,10 @@ router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunctio
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
...(status ? { status: String(status) as any } : {}),
...(type ? { type: String(type) as any } : {}),
...(dateFrom ? { createdAt: { gte: new Date(String(dateFrom)) } } : {}),
...(dateTo ? { createdAt: { lte: new Date(String(dateTo) + 'T23:59:59') } } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
orderBy: [{ createdAt: 'desc' }, { month: 'desc' }, { batchNo: 'asc' }],
})
res.json({ success: true, data: batches })
} catch (err) {
@@ -281,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: {
@@ -355,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 }
}
}
// 风险提示
@@ -474,9 +506,10 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...calcResult },
data: { ...inputs, ...entryData },
})
// 更新批次汇总
@@ -589,11 +622,12 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...calcResult, riskWarnings,
...entryData, riskWarnings,
},
})
results.push(entry)
@@ -731,9 +765,10 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...calcResult },
data: { ...entryData },
})
} catch (e: any) {
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
+1 -1
View File
@@ -115,7 +115,7 @@ router.get('/orgs', async (req: AuthRequest, res, next) => {
select: {
id: true, name: true, plan: true, maxEmployees: true,
city: true, contactName: true, contactPhone: true,
payrollFrequency: true, retirementReminderEnabled: true,
payrollDays: true, retirementReminderEnabled: true,
createdAt: true, updatedAt: true,
_count: {
select: { employees: true, users: true, contracts: true, payslips: true },
+52 -5
View File
@@ -1,5 +1,6 @@
import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/rbac'
import { z } from 'zod'
import prisma from '../lib/prisma'
import {
@@ -90,24 +91,26 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, ne
router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } })
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const [totalEmployees, readRecords] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
const [allEmployees, readRecords] = await Promise.all([
prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, select: { id: true, name: true, department: true }, orderBy: { name: 'asc' } }),
prisma.policyReadRecord.findMany({
where: { policyId: req.params.id, orgId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { readAt: 'desc' },
}),
])
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
const unreadEmployees = allEmployees.filter(e => !readEmpIds.has(e.id))
res.json({
success: true,
data: {
total: totalEmployees,
total: allEmployees.length,
readCount: readRecords.length,
unreadCount: totalEmployees - readRecords.length,
unreadCount: unreadEmployees.length,
records: readRecords.map(r => ({
employeeId: r.employeeId,
employeeName: r.employee.name,
@@ -115,6 +118,11 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
readAt: r.readAt.toISOString(),
ip: r.ip,
})),
unreadEmployees: unreadEmployees.map(e => ({
employeeId: e.id,
employeeName: e.name,
department: e.department,
})),
},
})
} catch (err) {
@@ -122,4 +130,43 @@ router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Resp
}
})
/** 催办未签收员工 */
router.post('/:id/remind', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true, title: true } })
if (!policy) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } })
}
const { employeeIds } = req.body as { employeeIds?: string[] }
const readRecords = await prisma.policyReadRecord.findMany({ where: { policyId: req.params.id, orgId }, select: { employeeId: true } })
const readEmpIds = new Set(readRecords.map(r => r.employeeId))
const targetEmployees = await prisma.employee.findMany({
where: {
orgId, status: 'ACTIVE',
id: employeeIds && employeeIds.length > 0 ? { in: employeeIds } : undefined,
},
select: { id: true, name: true },
})
const unreadEmployees = targetEmployees.filter(e => !readEmpIds.has(e.id))
// 创建催办通知
for (const emp of unreadEmployees) {
await prisma.notificationLog.create({
data: {
orgId,
employeeId: emp.id,
type: 'POLICY_REMIND',
title: `制度签收提醒:${policy.title}`,
content: `您有一项制度「${policy.title}」尚未签收,请尽快完成阅读确认。`,
channel: 'IN_APP',
status: 'SENT',
},
}).catch(() => {})
}
res.json({ success: true, data: { reminded: unreadEmployees.length } })
} catch (err) {
next(err)
}
})
export default router
+327 -4
View File
@@ -115,7 +115,11 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => {
if (!payslip) {
return res.json({ success: true, data: null })
}
res.json({ success: true, data: payslip })
// 记录查看时间
if (!payslip.viewedAt) {
await prisma.payslip.update({ where: { id: payslip.id }, data: { viewedAt: new Date() } })
}
res.json({ success: true, data: { ...payslip, viewedAt: payslip.viewedAt || new Date() } })
} catch (err) {
next(err)
}
@@ -167,6 +171,25 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.employee.id,
}).catch(() => {})
// 如果开启了工资条电子签,创建电子签记录
const org = await prisma.organization.findUnique({ where: { id: req.employee.orgId }, select: { esignPayslipEnabled: true } })
if (org?.esignPayslipEnabled) {
await prisma.eSignRecord.create({
data: {
orgId: req.employee.orgId,
employeeId: req.employee.id,
scene: 'PAYSLIP',
documentTitle: `工资条确认:${payslip.month}`,
status: 'PENDING',
initiatedBy: req.employee.id,
createdBy: req.employee.id,
remark: '工资条确认时自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
}
res.json({ success: true })
} catch (err) {
next(err)
@@ -540,6 +563,27 @@ router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response
userAgent: req.headers['user-agent'] || null,
},
})
// 如果开启了制度电子签,创建电子签记录
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { esignPolicyEnabled: true } })
if (org?.esignPolicyEnabled) {
const policyDoc = await prisma.policyDocument.findUnique({ where: { id: req.params.id }, select: { title: true, content: true } })
await prisma.eSignRecord.create({
data: {
orgId,
employeeId,
scene: 'POLICY',
documentTitle: `制度签收:${policyDoc?.title || '未知'}`,
documentContent: policyDoc?.content || null,
status: 'PENDING',
initiatedBy: employeeId,
createdBy: employeeId,
remark: '制度阅读确认后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
}
res.json({ success: true, data: { readAt: record.readAt.toISOString() } })
} catch (err) {
next(err)
@@ -701,6 +745,14 @@ router.get('/home/overview', portalAuth, async (req: any, res, next) => {
if (unreadPolicies.length > 0) {
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
}
// 待签署文件
const pendingEsign = await prisma.eSignRecord.findMany({
where: { employeeId, orgId, status: 'PENDING' },
select: { id: true, documentTitle: true },
})
if (pendingEsign.length > 0) {
pendingTasks.push({ severity: 'high', message: `您有 ${pendingEsign.length} 份文件待签署(${pendingEsign.map(e => e.documentTitle).join('、')}` })
}
res.json({
success: true,
@@ -773,7 +825,7 @@ router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => {
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { reason, expectedDate, remark } = req.body
const { reason, expectedDate, remark, attachments } = req.body
if (!reason || !expectedDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请填写离职原因和预计离职日期' } })
}
@@ -789,6 +841,7 @@ router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
if (existing) {
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '您已有一个待处理的离职申请' } })
}
const remarkText = `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}${attachments && attachments.length > 0 ? `;附件:${attachments.length}张辞职信照片` : ''}`
const record = await (prisma as any).terminationRecord.create({
data: {
employeeId, orgId,
@@ -797,8 +850,8 @@ router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
resignationReason: reason,
terminationDate: new Date(expectedDate),
status: 'PENDING_APPROVAL',
checklist: [],
remark: `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}`,
checklist: attachments && attachments.length > 0 ? attachments : [],
remark: remarkText,
createdBy: employeeId,
},
})
@@ -838,6 +891,82 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
} catch (err) { next(err) }
})
// 下载离职证明(仅已完成的离职记录)
router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await (prisma as any).terminationRecord.findFirst({
where: { id: req.params.id, employeeId, orgId },
include: { employee: true },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职记录不存在' } })
if (record.status !== 'COMPLETED') {
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '离职流程未完成,无法下载证明' } })
}
const org = await prisma.organization.findFirst({ where: { id: orgId } })
const orgName = org?.name || ''
// 查找企业自定义的离职证明模板
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
where: { orgId, category: 'LEAVING_CERT' },
})
const reasonLabel: Record<string, string> = {
RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退',
NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡',
}
const reason = reasonLabel[record.reason] || record.reason || ''
const variables: Record<string, string> = {
employeeName: record.employee?.name || '',
idCardNumber: record.employee?.idCardNumber || '',
department: record.employee?.department || '',
position: record.employee?.position || '',
hireDate: record.employee?.hireDate ? new Date(record.employee.hireDate).toISOString().slice(0, 10) : '',
leaveDate: record.terminationDate ? new Date(record.terminationDate).toISOString().slice(0, 10) : '',
reason,
companyName: orgName,
compensation: String(record.compensation || 0),
socialInsEndMonth: record.socialInsEndMonth || '',
housingFundEndMonth: record.housingFundEndMonth || '',
}
let content: string
if (tpl) {
content = tpl.content
for (const [key, value] of Object.entries(variables)) {
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
} else {
content = `<h1>解除/终止劳动合同证明书</h1>
<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>`
}
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>离职证明</title>
<!--[if gte mso 9]><xml>
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
</xml><![endif]-->
<style>
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
p { text-indent: 2em; margin: 0 0 10pt 0; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
</style></head>
<body>${content}</body></html>`
const encoded = encodeURIComponent(`离职证明-${variables.employeeName}.doc`)
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(htmlContent)
} catch (err) { next(err) }
})
// ========== 员工端:休假申请 ==========
// 查看自己的休假申请列表
router.get('/leaves', portalAuth, async (req: any, res, next) => {
@@ -898,4 +1027,198 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
} catch (err) { next(err) }
})
// ========== 员工端:电子签署 ==========
// 查看自己的签署记录列表
router.get('/esign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.eSignRecord.findMany({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 查看签署详情
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 员工签署操作(预留:对接易签宝后跳转到签署页面或提交签署结果)
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果
// 当前框架阶段:直接标记为已签署
const updated = await prisma.eSignRecord.update({
where: { id: record.id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
})
// 如果关联了合同,更新合同签署信息
if (record.contractId) {
await prisma.laborContract.update({
where: { id: record.contractId },
data: { signMethod: 'ELECTRONIC' },
})
}
res.json({ success: true, data: updated, message: '签署成功' })
} catch (err) { next(err) }
})
// ========== 员工端:培训签收 ==========
// 查看自己的培训记录
router.get('/training', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.trainingRecord.findMany({
where: { employeeId, orgId },
orderBy: { trainingDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 培训签收
router.post('/training/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签收' } })
const updated = await prisma.trainingRecord.update({
where: { id: record.id },
data: { ackStatus: 'SIGNED', ackDate: new Date() },
})
await createEvidence({
orgId,
category: 'TRAINING',
refId: record.id,
employeeId,
events: [{ action: `培训签收:${record.topic}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: employeeId,
}).catch(() => {})
res.json({ success: true, data: updated, message: '签收成功' })
} catch (err) { next(err) }
})
// 培训拒绝签收
router.post('/training/:id/refuse', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
const updated = await prisma.trainingRecord.update({
where: { id: record.id },
data: { ackStatus: 'REFUSED', ackDate: new Date() },
})
res.json({ success: true, data: updated, message: '已拒绝签收' })
} catch (err) { next(err) }
})
// ========== 员工端:绩效签字 ==========
// 查看自己的绩效记录
router.get('/performance', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.performanceRecord.findMany({
where: { employeeId, orgId },
orderBy: { period: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 绩效签字确认
router.post('/performance/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, employeeAck: false },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } })
const updated = await prisma.performanceRecord.update({
where: { id: record.id },
data: { employeeAck: true, ackDate: new Date() },
})
await createEvidence({
orgId,
category: 'PERFORMANCE',
refId: record.id,
employeeId,
events: [{ action: `绩效签字确认:${record.period}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: employeeId,
}).catch(() => {})
res.json({ success: true, data: updated, message: '签字成功' })
} catch (err) { next(err) }
})
// ========== 员工端:违纪签字 ==========
// 查看自己的违纪记录
router.get('/disciplinary', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await prisma.disciplinaryRecord.findMany({
where: { employeeId, orgId },
orderBy: { violationDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 违纪签字确认
router.post('/disciplinary/:id/sign', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.id, employeeId, orgId, employeeAck: false },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } })
const updated = await prisma.disciplinaryRecord.update({
where: { id: record.id },
data: { employeeAck: true, ackDate: new Date(), ackMethod: 'SIGN' },
})
await createEvidence({
orgId,
category: 'DISCIPLINARY',
refId: record.id,
employeeId,
events: [{ action: `违纪签字确认:${record.violationType}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: employeeId,
}).catch(() => {})
res.json({ success: true, data: updated, message: '签字成功' })
} catch (err) { next(err) }
})
export default router
+74
View File
@@ -0,0 +1,74 @@
/**
* 岗位字典路由
* 提供岗位的增删改查
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const createPositionSchema = z.object({
name: z.string().min(1, '岗位名称必填'),
departmentId: z.string().nullable().optional(),
headcount: z.number().int().min(0).default(0),
level: z.string().max(20).optional(),
description: z.string().max(200).optional(),
})
/** 获取岗位列表 */
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const positions = await prisma.position.findMany({
where: { orgId: req.user!.orgId! },
orderBy: { createdAt: 'asc' },
include: { department: { select: { id: true, name: true } } },
})
res.json({ success: true, data: positions })
} catch (err) {
next(err)
}
})
/** 创建岗位 */
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createPositionSchema.parse(req.body)
const position = await prisma.position.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: position })
} catch (err) {
next(err)
}
})
/** 更新岗位 */
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = createPositionSchema.partial().parse(req.body)
const position = await prisma.position.update({ where: { id }, data })
res.json({ success: true, data: position })
} catch (err) {
next(err)
}
})
/** 删除岗位 */
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await prisma.position.delete({ where: { id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+354 -19
View File
@@ -5,6 +5,7 @@ import { createEvidence } from '../services/evidence.service'
import prisma from '../lib/prisma'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
import { calcSocialInsurance, calcHousingFund } from '../services/payroll.service'
import ExcelJS from 'exceljs'
const router = Router()
@@ -18,6 +19,16 @@ function safeDecrypt(encrypted: string): number {
}
}
function safeDecryptStr(encrypted: string | null): string | null {
if (!encrypted) return null
try {
if (!encrypted.includes(':')) return encrypted
return decrypt(encrypted)
} catch {
return encrypted
}
}
// ========== 花名册聚合 API ==========
// 获取部门列表(去重)
@@ -83,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([
@@ -95,6 +106,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
socialInsRecords: { orderBy: { startMonth: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
@@ -109,6 +121,32 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
}),
])
// 获取社保和公积金配置(按城市缓存)
const currentMonth = new Date().toISOString().slice(0, 7)
const configCache = new Map<string, { social?: any; housing?: any }>()
const getConfigsForCity = async (city?: string) => {
const key = city || '_default'
if (configCache.has(key)) return configCache.get(key)!
const cityWhere = city ? { orgId: req.user!.orgId, city } : { orgId: req.user!.orgId }
const [socialCfg, housingCfg] = await Promise.all([
prisma.socialInsuranceConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const result = { social: socialCfg, housing: housingCfg }
configCache.set(key, result)
return result
}
// 预加载所有涉及城市的配置
const cities = [...new Set(employees.map((e) => e.city).filter(Boolean))] as string[]
await Promise.all(cities.map((c) => getConfigsForCity(c)))
// 计算动态状态和合同状态
let result = employees.map((e) => {
const latestContract = e.contracts[0] || null
@@ -132,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 {
@@ -148,6 +186,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
id: e.id,
name: e.name,
department: e.department,
position: e.position,
city: e.city,
status: dynamicStatus,
hasTermination: e.terminations.length > 0,
@@ -159,8 +198,22 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
gender: e.gender,
phone: e.phone,
idCardMasked,
idCardNumber: e.idCardNumber,
idCardNumber: safeDecryptStr(e.idCardNumber),
monthlySalary: safeDecrypt(e.monthlySalary),
socialInsBase: e.socialInsBase,
housingFundBase: e.housingFundBase,
socialInsCalc: (() => {
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.social || !e.socialInsBase) return null
const r = calcSocialInsurance(e.socialInsBase, cfgs.social)
return { socialEmp: r.socialEmp, socialOrg: r.socialOrg }
})(),
housingFundCalc: (() => {
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.housing || !e.housingFundBase) return null
const r = calcHousingFund(e.housingFundBase, cfgs.housing)
return { housingEmp: r.housingEmp, housingOrg: r.housingOrg }
})(),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
isWorkInjured: e.isWorkInjured,
@@ -168,6 +221,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
socialInsuranceStatus: (() => {
const sr = (e as any).socialInsRecords?.[0]
if (!sr) return null
// endMonth 为 null 表示在保,否则已停保
if (sr.endMonth) return 'SUSPENDED'
return 'ACTIVE'
})(),
probationInfo: (() => {
if (!latestContract || latestContract.probationMonths === 0) return null
const probEnd = new Date(e.hireDate)
@@ -190,16 +250,11 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 身份证号后N位搜索:在内存中过滤(解密完整身份证号后匹配
// 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文
if (isIdCardSearch) {
result = result.filter((e: any) => {
if (!e.idCardNumber) return false
try {
const fullIdCard = decrypt(e.idCardNumber)
return fullIdCard.endsWith(search!)
} catch {
return false
}
return String(e.idCardNumber).endsWith(search!)
})
}
@@ -303,7 +358,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
status: dynamicStatus,
monthlySalary: safeDecrypt(monthlySalary),
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
idCardNumber: safeDecryptStr(idCardNumber),
monthlyProcessRecords,
},
})
@@ -727,16 +782,145 @@ router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest
wsRisk.getRow(1).font = { bold: true }
risks.forEach((r, i) => wsRisk.addRow({ no: i + 1, ...r }))
const encodedName = encodeURIComponent(empName)
const fullFileName = `${empName}_证据链.xlsx`
const encodedName = encodeURIComponent(fullFileName)
const asciiFallback = `evidence_chain_${employee.id.slice(-8)}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}_证据链.xlsx"; filename*=UTF-8''${encodedName}_证据链.xlsx`)
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
res.setHeader('Content-Disposition', `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedName}`)
const buffer = await workbook.xlsx.writeBuffer()
res.send(Buffer.from(buffer))
} catch (err: any) {
console.error('证据链导出失败:', err?.message || err)
if (!res.headersSent) {
res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } })
} else {
res.end()
}
}
})
// ========== 组织级列表查询 ==========
// 培训记录列表(全员)
router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const keyword = (req.query.keyword as string) || ''
const ackStatus = (req.query.ackStatus as string) || ''
const where: any = { orgId }
if (ackStatus) {
where.ackStatus = ackStatus
}
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.trainingRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { trainingDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.trainingRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// 培训记录催办(发送通知给未签收员工)
router.post('/training/remind/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.recordId, orgId },
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '培训记录不存在' } })
}
if (record.ackStatus !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅待签收记录可催办' } })
}
// 记录催办通知日志
await prisma.notificationLog.create({
data: {
orgId,
type: 'TRAINING_REMIND',
title: `培训签收催办:${record.topic}`,
content: `员工 ${record.employee.name}${record.employee.department})的培训记录「${record.topic}」尚未签收,请尽快完成签收。`,
channel: 'SYSTEM',
status: 'SENT',
},
})
res.json({ success: true, data: { message: `已催办 ${record.employee.name} 签收「${record.topic}` } })
} catch (err) { next(err) }
})
// 绩效记录列表(全员)
router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const keyword = (req.query.keyword as string) || ''
const where: any = { orgId }
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.performanceRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { period: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.performanceRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// 违纪记录列表(全员)
router.get('/disciplinary/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const keyword = (req.query.keyword as string) || ''
const where: any = { orgId }
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.disciplinaryRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { violationDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.disciplinaryRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// ========== 违纪记录 CRUD ==========
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
@@ -821,6 +1005,55 @@ router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req:
} catch (err) { next(err) }
})
// 违纪确认证明导出
router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
include: { employee: true },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
}
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' }
let idCard = ''
try { if (record.employee.idCardNumber) idCard = decrypt(record.employee.idCardNumber) } catch { idCard = record.employee.idCardNumber || '' }
const content = `违纪确认证明
兹证明 ${record.employee.name}(证件号码:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
违纪类型:${typeMap[record.violationType] || record.violationType}
严重程度:${severityMap[record.severity] || record.severity}
违纪事实:${record.description}
处理结果:${actionMap[record.action] || record.action}${record.actionDetail ? `${record.actionDetail}` : ''}
${record.employeeAck ? `该员工已于 ${record.ackDate ? new Date(record.ackDate).toISOString().slice(0, 10) : '___'} 签字确认上述违纪事实及处理结果。${record.witness ? `见证人:${record.witness}` : ''}` : '该员工尚未签字确认。'}
特此证明。
${org?.name || ''}
${new Date().toLocaleDateString('zh-CN')}`
const blob = Buffer.from('\ufeff' + content, 'utf8')
const certFileName = `${record.employee.name}_违纪确认证明.doc`
const encodedCertName = encodeURIComponent(certFileName)
const asciiCertFallback = `disciplinary_cert_${record.id.slice(-8)}.doc`
res.setHeader('Content-Type', 'application/msword;charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${asciiCertFallback}"; filename*=UTF-8''${encodedCertName}`)
res.send(blob)
} catch (err: any) {
console.error('违纪确认证明导出失败:', err?.message || err)
if (!res.headersSent) {
res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } })
}
}
})
// ========== 考勤记录 CRUD ==========
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
@@ -915,6 +1148,33 @@ router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, re
} catch (err) { next(err) }
})
// 批量创建培训记录
router.post('/training/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeIds, trainingDate, topic, content, trainer, duration, remark } = req.body
if (!employeeIds || !Array.isArray(employeeIds) || employeeIds.length === 0) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '请至少选择一名员工' } })
}
const results = await Promise.all(employeeIds.map((empId: string) =>
prisma.trainingRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: empId,
trainingDate: new Date(trainingDate),
topic,
content,
trainer,
duration: duration || 0,
ackStatus: 'PENDING',
remark,
createdBy: req.user!.id,
},
})
))
res.json({ success: true, data: { count: results.length } })
} catch (err) { next(err) }
})
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
@@ -965,29 +1225,35 @@ router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.upsert({
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
create: {
orgId: req.user!.orgId,
employeeId: req.params.employeeId,
period,
periodType: periodType || 'MONTHLY',
score: score || 0,
grade: grade || 'B',
result: result || 'QUALIFIED',
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
createdBy: req.user!.id,
},
update: {
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
@@ -1000,7 +1266,7 @@ router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest,
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
@@ -1009,11 +1275,14 @@ router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: Aut
where: { id: req.params.recordId },
data: {
period,
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
@@ -1034,6 +1303,72 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req:
} catch (err) { next(err) }
})
// ========== 绩效模板 CRUD ==========
// 获取模板列表
router.get('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const templates = await prisma.performanceTemplate.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: templates })
} catch (err) { next(err) }
})
// 创建模板
router.post('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
if (!name || !dimensions || !Array.isArray(dimensions)) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '模板名称和考核维度为必填' } })
}
// 如果设为默认,先取消其他默认
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true }, data: { isDefault: false } })
}
const template = await prisma.performanceTemplate.create({
data: {
orgId: req.user!.orgId,
name,
description,
dimensions,
gradeRules: gradeRules || undefined,
isDefault: isDefault || false,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: template })
} catch (err) { next(err) }
})
// 更新模板
router.put('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
}
const updated = await prisma.performanceTemplate.update({
where: { id: req.params.id },
data: { name, description, dimensions, gradeRules: gradeRules || undefined, isDefault },
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// 删除模板
router.delete('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
await prisma.performanceTemplate.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 调薪/调部门 API ==========
function dateToMonth(date: Date): string {
+106 -4
View File
@@ -28,7 +28,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
try {
const org = await prisma.organization.findUnique({
where: { id: req.user!.orgId },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true, createdAt: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true, createdAt: true },
})
res.json({ success: true, data: org })
} catch (err) {
@@ -39,18 +39,22 @@ router.get('/org', async (req: AuthRequest, res, next) => {
// 更新企业信息
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const { name, payrollFrequency, city, contactName, contactPhone, retirementReminderEnabled } = req.body as { name?: string; payrollFrequency?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean }
const { name, payrollDays, payrollReminderDays, city, contactName, contactPhone, retirementReminderEnabled, esignPolicyEnabled, esignPayslipEnabled, esignOnboardingEnabled } = req.body as { name?: string; payrollDays?: number[]; payrollReminderDays?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean; esignPolicyEnabled?: boolean; esignPayslipEnabled?: boolean; esignOnboardingEnabled?: boolean }
const updateData: any = {}
if (name) updateData.name = name
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
if (payrollDays !== undefined) updateData.payrollDays = payrollDays
if (payrollReminderDays !== undefined) updateData.payrollReminderDays = payrollReminderDays
if (city !== undefined) updateData.city = city
if (contactName !== undefined) updateData.contactName = contactName
if (contactPhone !== undefined) updateData.contactPhone = contactPhone
if (retirementReminderEnabled !== undefined) updateData.retirementReminderEnabled = retirementReminderEnabled
if (esignPolicyEnabled !== undefined) updateData.esignPolicyEnabled = esignPolicyEnabled
if (esignPayslipEnabled !== undefined) updateData.esignPayslipEnabled = esignPayslipEnabled
if (esignOnboardingEnabled !== undefined) updateData.esignOnboardingEnabled = esignOnboardingEnabled
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: updateData,
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true },
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollDays: true, payrollReminderDays: true, retirementReminderEnabled: true, esignPolicyEnabled: true, esignPayslipEnabled: true, esignOnboardingEnabled: true },
})
res.json({ success: true, data: org })
} catch (err) {
@@ -241,3 +245,101 @@ router.post('/retirement-policy/:id/confirm', requireAdmin, async (req: AuthRequ
}
})
// ========== 医疗期政策配置 ==========
const DEFAULT_POLICIES = [
{
region: '全国',
legalBasis: '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)',
rules: [
{ maxYears: 5, months: 3, cycleMonths: 6 },
{ maxYears: 10, months: 6, cycleMonths: 12 },
{ maxYears: 15, months: 9, cycleMonths: 15 },
{ maxYears: 20, months: 12, cycleMonths: 18 },
{ maxYears: 999, months: 24, cycleMonths: 30 },
],
isDefault: true,
},
{
region: '上海',
legalBasis: '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》',
rules: [
{ maxYears: 1, months: 3, cycleMonths: 6 },
{ maxYears: 4, months: 3, cycleMonths: 6 },
{ maxYears: 10, months: 6, cycleMonths: 12 },
{ maxYears: 999, months: 9, cycleMonths: 18 },
],
isDefault: false,
},
]
// 获取医疗期政策列表
router.get('/medical-period/policies', async (req: AuthRequest, res, next) => {
try {
let policies = await prisma.medicalPeriodPolicy.findMany({
where: { orgId: req.user!.orgId },
orderBy: [{ isDefault: 'desc' }, { region: 'asc' }],
})
if (policies.length === 0) {
policies = await prisma.$transaction(
DEFAULT_POLICIES.map(p =>
prisma.medicalPeriodPolicy.create({
data: { orgId: req.user!.orgId, ...p },
})
)
)
}
res.json({ success: true, data: policies })
} catch (err) {
next(err)
}
})
// 新增/编辑医疗期政策
const medicalPolicySchema = z.object({
region: z.string().min(1, '地区名称不能为空'),
legalBasis: z.string().min(1, '法律依据不能为空'),
rules: z.array(z.object({
maxYears: z.number().min(0),
months: z.number().min(1),
cycleMonths: z.number().min(1),
})).min(1, '至少需要一条分档规则'),
isDefault: z.boolean().default(false),
})
router.post('/medical-period/policies', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const data = medicalPolicySchema.parse(req.body)
if (data.isDefault) {
await prisma.medicalPeriodPolicy.updateMany({
where: { orgId: req.user!.orgId },
data: { isDefault: false },
})
}
const policy = await prisma.medicalPeriodPolicy.upsert({
where: { orgId_region: { orgId: req.user!.orgId, region: data.region } },
update: { legalBasis: data.legalBasis, rules: data.rules, isDefault: data.isDefault },
create: { orgId: req.user!.orgId, ...data },
})
res.json({ success: true, data: policy })
} catch (err: any) {
if (err.issues) return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: err.issues[0]?.message } })
next(err)
}
})
// 删除医疗期政策
router.delete('/medical-period/policies/:id', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const policy = await prisma.medicalPeriodPolicy.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!policy) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '政策不存在' } })
if (policy.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除默认政策' } })
await prisma.medicalPeriodPolicy.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
+71 -4
View File
@@ -9,7 +9,7 @@ const router = Router()
router.use(authMiddleware)
const socialConfigFields = {
city: z.string().optional(),
city: z.string().min(1),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(),
@@ -26,7 +26,7 @@ const socialConfigFields = {
}
const housingConfigFields = {
city: z.string().optional(),
city: z.string().min(1),
accountType: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
@@ -136,9 +136,9 @@ router.post('/config/versions', async (req: AuthRequest, res: Response, next: Ne
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
}
// 将之前当前版本标记为失效
// 将之前当前版本标记为失效(按城市过滤)
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
where: { orgId, city: data.city, isCurrent: true },
})
if (prevCurrent) {
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
@@ -1511,4 +1511,71 @@ router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFun
}
})
// ========== 员工参保信息列表 ==========
router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const keyword = (req.query.keyword as string) || ''
// 查询所有在职员工
const employees = await prisma.employee.findMany({
where: {
orgId,
status: 'ACTIVE',
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
},
select: {
id: true,
name: true,
department: true,
position: true,
socialInsBase: true,
housingFundBase: true,
city: true,
},
orderBy: { department: 'asc' },
})
const empIds = employees.map(e => e.id)
// 查询当前有效的社保记录(endMonth 为 null
const socialRecords = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, employeeId: { in: empIds }, endMonth: null },
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
})
// 查询当前有效的公积金记录
const housingRecords = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, employeeId: { in: empIds }, endMonth: null },
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
})
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
const list = employees.map(emp => {
const social = socialMap.get(emp.id)
const housing = housingMap.get(emp.id)
return {
id: emp.id,
name: emp.name,
department: emp.department,
position: emp.position,
socialInsBase: social?.base ?? emp.socialInsBase ?? 0,
socialInsCity: social?.city ?? emp.city ?? '',
socialInsStart: social?.startMonth ?? '',
socialInsStatus: social ? 'INSURED' : 'UNINSURED',
housingFundBase: housing?.base ?? emp.housingFundBase ?? 0,
housingFundCity: housing?.city ?? emp.city ?? '',
housingFundStart: housing?.startMonth ?? '',
housingFundStatus: housing ? 'INSURED' : 'UNINSURED',
}
})
res.json({ success: true, data: list })
} catch (err) {
next(err)
}
})
export default router
+237
View File
@@ -0,0 +1,237 @@
/**
* 客服工作台路由
* 提供工单管理、客户会话、租户数据穿透
*/
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
// 客服权限中间件
const supportMiddleware = (req: AuthRequest, res: any, next: any) => {
if (req.user!.role !== 'SUPPORT' && req.user!.role !== 'SUPER_ADMIN') {
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '仅客服或超级管理员可访问' } })
}
next()
}
const createTicketSchema = z.object({
title: z.string().min(1, '标题必填'),
content: z.string().min(1, '内容必填'),
category: z.string().optional(),
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
})
const createMessageSchema = z.object({
content: z.string().min(1, '内容必填'),
})
// ==================== 工单管理 ====================
/** 获取工单列表(客服看全部,企业用户看自己租户的) */
router.get('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const where = isSupport ? {} : { orgId: req.user!.orgId! }
const tickets = await prisma.ticket.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
org: { select: { id: true, name: true } },
_count: { select: { messages: true } },
},
})
res.json({ success: true, data: tickets })
} catch (err) {
next(err)
}
})
/** 获取工单详情 */
router.get('/tickets/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const ticket = await prisma.ticket.findUnique({
where: { id },
include: {
org: { select: { id: true, name: true } },
messages: { orderBy: { createdAt: 'asc' } },
},
})
if (!ticket) throw { code: 'NOT_FOUND', message: '工单不存在' }
res.json({ success: true, data: ticket })
} catch (err) {
next(err)
}
})
/** 创建工单(企业用户提交) */
router.post('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createTicketSchema.parse(req.body)
const ticket = await prisma.ticket.create({
data: {
...data,
orgId: req.user!.orgId!,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: ticket })
} catch (err) {
next(err)
}
})
/** 回复工单 */
router.post('/tickets/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const data = createMessageSchema.parse(req.body)
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const message = await prisma.ticketMessage.create({
data: {
ticketId: id,
content: data.content,
senderId: req.user!.id,
senderRole: isSupport ? 'SUPPORT' : 'USER',
},
})
// 客服回复时更新工单状态为处理中
if (isSupport) {
await prisma.ticket.update({ where: { id }, data: { status: 'IN_PROGRESS', assigneeId: req.user!.id } })
}
res.json({ success: true, data: message })
} catch (err) {
next(err)
}
})
/** 关闭工单 */
router.post('/tickets/:id/close', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
await prisma.ticket.update({ where: { id }, data: { status: 'CLOSED' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
/** 转派工单 */
router.post('/tickets/:id/assign', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { assigneeId } = req.body
await prisma.ticket.update({ where: { id }, data: { assigneeId } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ==================== 客户会话 ====================
/** 获取会话列表 */
router.get('/chat/sessions', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const where = isSupport ? { supportUserId: req.user!.id } : { orgId: req.user!.orgId! }
const sessions = await prisma.chatSession.findMany({
where,
orderBy: { lastMessageAt: 'desc' },
include: { org: { select: { id: true, name: true } } },
})
res.json({ success: true, data: sessions })
} catch (err) {
next(err)
}
})
/** 获取会话消息 */
router.get('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const messages = await prisma.chatMessage.findMany({
where: { sessionId: id },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: messages })
} catch (err) {
next(err)
}
})
/** 发送会话消息 */
router.post('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { content } = req.body as { content: string }
if (!content) throw { code: 'VALIDATION_ERROR', message: '内容必填' }
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
const session = await prisma.chatSession.findUnique({ where: { id } })
if (!session) throw { code: 'NOT_FOUND', message: '会话不存在' }
const message = await prisma.chatMessage.create({
data: {
sessionId: id,
content,
senderId: req.user!.id,
senderRole: isSupport ? 'SUPPORT' : 'USER',
},
})
await prisma.chatSession.update({
where: { id },
data: {
lastMessage: content,
lastMessageAt: new Date(),
unreadBySupport: isSupport ? session.unreadBySupport : session.unreadBySupport + 1,
unreadByUser: isSupport ? session.unreadByUser + 1 : session.unreadByUser,
},
})
res.json({ success: true, data: message })
} catch (err) {
next(err)
}
})
// ==================== 租户数据穿透 ====================
/** 获取租户列表(客服用) */
router.get('/tenants', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const tenants = await prisma.organization.findMany({
select: {
id: true, name: true, plan: true, maxEmployees: true,
contactName: true, contactPhone: true, createdAt: true,
_count: { select: { employees: true, users: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: tenants })
} catch (err) {
next(err)
}
})
/** 获取租户数据概览(客服代查看) */
router.get('/tenants/:orgId/overview', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
try {
const { orgId } = req.params
const [employeeCount, activeContracts, pendingApprovals, openTickets] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
prisma.laborContract.count({ where: { orgId } }),
prisma.approvalInstance.count({ where: { orgId, status: 'PENDING' } }),
prisma.ticket.count({ where: { orgId, status: { in: ['OPEN', 'IN_PROGRESS'] } } }),
])
res.json({
success: true,
data: { employeeCount, activeContracts, pendingApprovals, openTickets },
})
} catch (err) {
next(err)
}
})
export default router
+60 -3
View File
@@ -42,17 +42,74 @@ router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Respons
}
})
/** 下载模板(Word .doc 格式) */
/** 下载模板(Word .doc 格式,支持变量替换 */
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const template = getTemplateById(req.params.id)
if (!template) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
}
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx
const variables: Record<string, string> = {}
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string' && key !== 'token') variables[key] = value
}
let content = template.content
if (Object.keys(variables).length > 0) {
content = renderTemplate(req.params.id, variables) || content
}
// 将纯文本转换为HTML段落,使Word样式生效
const textToHtml = (text: string): string => {
const lines = text.split(/\n/)
let html = ''
let inTable = false
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
if (inTable) { html += '</table>'; inTable = false }
html += '<p style="text-indent:0">&nbsp;</p>'
continue
}
// 标题检测:以"第X条"开头的行作为小标题
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<h3>${trimmed}</h3>`
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<h1>${trimmed}</h1>`
} else if (/^(甲方|乙方)(盖章|签字)/.test(trimmed) || /^日期[:]/.test(trimmed)) {
if (inTable) { html += '</table>'; inTable = false }
html += `<p class="sign">${trimmed}</p>`
} else {
if (inTable) { html += '</table>'; inTable = false }
html += `<p>${trimmed}</p>`
}
}
if (inTable) html += '</table>'
return html
}
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>${template.name}</title>
<!--[if gte mso 9]><xml>
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
</xml><![endif]-->
<style>
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
p { text-indent: 2em; margin: 0 0 10pt 0; }
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
th { background: #f0f0f0; font-weight: bold; text-align: center; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
</style></head>
<body>${textToHtml(content)}</body></html>`
const encoded = encodeURIComponent(template.name + '.doc')
res.setHeader('Content-Type', 'application/msword')
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
res.send(template.content)
res.send(htmlContent)
} catch (err) {
next(err)
}
+20
View File
@@ -340,4 +340,24 @@ router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest,
}
})
// 删除草稿(仅允许 DRAFT 和 CANCELLED 状态)
router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
}
if (record.status !== 'DRAFT' && record.status !== 'CANCELLED') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅草稿或已撤销的记录可以删除' } })
}
await prisma.terminationRecord.delete({ where: { id: req.params.id } })
await auditLog(req, 'DELETE_DRAFT', 'TERMINATION_RECORD', req.params.id, { employeeId: record.employeeId })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+102
View File
@@ -7,11 +7,66 @@ import { createWorkProcessSchema, updateWorkProcessSchema } from '../schemas/wor
const router = Router()
// 各流程类型的必填字段映射
const REQUIRED_FIELDS: Record<string, string[]> = {
HIRE: ['name', 'department', 'hireDate', 'phone', 'idCardNumber'],
ONBOARD: ['employeeId', 'hireDate'],
CUSTOM_CONTRACT: ['employeeId', 'contractStartDate'],
INFO_SUBMIT: ['employeeId'],
CONFIRM: ['employeeId', 'confirmDate'],
CHANGE: ['contractId', 'newEndDate'],
RENEW: ['employeeId', 'newStartDate'],
SUSPEND: ['contractId', 'suspendDate'],
INCOME_CERT: ['employeeName', 'idCardNumber'],
TERMINATE: ['employeeId', 'terminateDate', 'reason'],
RESCIND: ['employeeId', 'rescindDate', 'reason'],
LEAVING_CERT: ['employeeName', 'idCardNumber', 'leaveDate'],
FLEXIBLE: ['name', 'idCardNumber', 'agreementStartDate'],
}
// 必填字段中文标签映射
const FIELD_LABELS: Record<string, string> = {
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
idCardNumber: '证件号码', employeeId: '员工ID', contractId: '合同ID',
contractStartDate: '合同开始日期', confirmDate: '转正日期',
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
suspendDate: '中止日期', employeeName: '员工姓名',
terminateDate: '终止日期', rescindDate: '解除日期', reason: '原因',
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,
@@ -108,6 +163,14 @@ router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Respons
if (process.status !== 'DRAFT') {
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可提交' } })
}
// 后端必填字段校验
const required = REQUIRED_FIELDS[process.type] || []
const fd = process.formData || {}
const missing = required.filter((key) => !fd[key] || String(fd[key]).trim() === '')
if (missing.length > 0) {
const labels = missing.map((k) => FIELD_LABELS[k] || k).join('、')
return res.status(400).json({ success: false, error: { code: 'MISSING_REQUIRED', message: `请填写必填项:${labels}` } })
}
// 执行业务联动
let execResult: any = {}
try {
@@ -164,6 +227,45 @@ router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Respon
...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }),
},
})
// 入职审批通过且开启了入职文件电子签,创建电子签记录
if (execResult.employeeId && process.type === 'ONBOARDING') {
const orgSettings = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { esignOnboardingEnabled: true } })
if (orgSettings?.esignOnboardingEnabled) {
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: execResult.employeeId,
scene: 'ONBOARDING',
documentTitle: '入职文件签署',
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: '入职审批通过后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
}
}
// 离职证明/收入证明审批通过后,如果有关联员工,创建电子签记录
if (execResult.employeeId && (process.type === 'LEAVING_CERT' || process.type === 'INCOME_CERT')) {
const docTitle = process.type === 'LEAVING_CERT' ? '离职证明签署' : '收入证明签署'
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: execResult.employeeId,
scene: process.type === 'LEAVING_CERT' ? 'RESIGNATION' : 'OTHER',
documentTitle: docTitle,
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: '文书审批通过后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}).catch(() => {})
}
res.json({ success: true, data: updated })
} catch (err) {
next(err)
+5 -1
View File
@@ -5,7 +5,7 @@ export const createEmployeeSchema = z.object({
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
hireDate: z.string().datetime(),
monthlySalary: z.string().min(1, '月薪不能为空'),
idCardNumber: z.string().min(18, '身份证号不能为空且必须18位').max(18, '身份证号必须18位'),
idCardNumber: z.string().min(18, '证件号码不能为空且必须18位').max(18, '证件号码必须18位'),
gender: z.enum(['男', '女']).optional(),
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
@@ -14,6 +14,7 @@ export const createEmployeeSchema = z.object({
isWorkInjured: z.boolean().default(false),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
position: z.string().max(50).optional(),
contract: z.object({
signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(),
@@ -48,6 +49,9 @@ export const updateEmployeeSchema = z.object({
specialDeduction: z.number().min(0).optional(),
city: z.string().max(20).optional(),
education: z.string().max(20).optional(),
position: z.string().max(50).optional(),
status: z.enum(['ACTIVE', 'PENDING_ONBOARD', 'RESIGNED', 'BLACKLISTED']).optional(),
cityChangeReason: z.string().max(200).optional(),
})
export const batchRenewSchema = z.object({
+162
View File
@@ -0,0 +1,162 @@
/**
* 审批流引擎
* 支持最多 3 步审批(发起人 → 直属上级 → 部门负责人)
*/
import prisma from '../lib/prisma'
interface ApprovalStep {
step: number
approverType: 'SUPERVISOR' | 'DEPT_HEAD' | 'PERSON'
approverId?: string
name: string
}
interface ApprovalRecord {
step: number
approverId: string
approverName: string
result: 'APPROVED' | 'REJECTED'
comment?: string
timestamp: string
}
/**
* 创建审批实例
*/
export async function createApprovalInstance(
orgId: string,
userId: string,
type: string,
bizId: string,
bizType: string,
employeeId: string,
): Promise<{ instance: any; firstApprover?: any }> {
// 查找该类型的审批流配置
const flow = await prisma.approvalFlow.findFirst({
where: { orgId, type, enabled: true },
})
if (!flow) {
// 无审批流配置,直接通过
return { instance: null }
}
const steps = flow.steps as unknown as ApprovalStep[]
if (!steps || steps.length === 0) {
return { instance: null }
}
const instance = await prisma.approvalInstance.create({
data: {
orgId,
flowId: flow.id,
type,
bizId,
bizType,
status: 'PENDING',
currentStep: 1,
approvals: [],
employeeId,
createdBy: userId,
},
})
// 计算第一步审批人
const firstApprover = await resolveApprover(orgId, employeeId, steps[0])
return { instance, firstApprover }
}
/**
* 根据步骤配置解析审批人
*/
async function resolveApprover(orgId: string, employeeId: string, step: ApprovalStep): Promise<any> {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { dept: true, supervisor: true },
})
if (!employee) return null
if (step.approverType === 'SUPERVISOR') {
return employee.supervisor ? { id: employee.supervisor.id, name: employee.supervisor.name } : null
} else if (step.approverType === 'DEPT_HEAD') {
// 部门负责人暂用部门创建人(简化实现)
if (employee.dept) {
return { id: employee.dept.createdBy, name: '部门负责人' }
}
return null
} else if (step.approverType === 'PERSON' && step.approverId) {
const approver = await prisma.employee.findFirst({ where: { id: step.approverId, orgId } })
return approver ? { id: approver.id, name: approver.name } : null
}
return null
}
/**
* 处理审批
*/
export async function processApproval(
orgId: string,
instanceId: string,
approverId: string,
approverName: string,
result: 'APPROVED' | 'REJECTED',
comment?: string,
): Promise<{ status: string; nextApprover?: any }> {
const instance = await prisma.approvalInstance.findFirst({
where: { id: instanceId, orgId },
include: { flow: true },
})
if (!instance) throw { code: 'NOT_FOUND', message: '审批实例不存在' }
if (instance.status !== 'PENDING') throw { code: 'VALIDATION_ERROR', message: '审批实例已处理' }
const steps = instance.flow.steps as unknown as ApprovalStep[]
const currentStepConfig = steps.find(s => s.step === instance.currentStep)
if (!currentStepConfig) throw { code: 'VALIDATION_ERROR', message: '步骤配置错误' }
// 记录审批结果
const approvals = (instance.approvals as unknown as ApprovalRecord[]) || []
approvals.push({
step: instance.currentStep,
approverId,
approverName,
result,
comment,
timestamp: new Date().toISOString(),
})
if (result === 'REJECTED') {
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'REJECTED', approvals: approvals as any },
})
return { status: 'REJECTED' }
}
// 查找下一步
const nextStepConfig = steps.find(s => s.step === instance.currentStep + 1)
if (!nextStepConfig) {
// 全部通过
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'APPROVED', approvals: approvals as any },
})
return { status: 'APPROVED' }
}
// 进入下一步
const nextApprover = await resolveApprover(orgId, instance.employeeId || '', nextStepConfig)
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { currentStep: instance.currentStep + 1, approvals: approvals as any },
})
return { status: 'PENDING', nextApprover }
}
/**
* 取消审批
*/
export async function cancelApproval(orgId: string, instanceId: string): Promise<void> {
await prisma.approvalInstance.update({
where: { id: instanceId },
data: { status: 'CANCELLED' },
})
}
+59 -4
View File
@@ -261,6 +261,60 @@ export async function deleteShiftAssignment(orgId: string, id: string) {
// ========== 每日出勤 ==========
export async function manualCorrectAttendance(orgId: string, data: {
employeeId: string
date: string
checkInTime?: string
checkOutTime?: string
status?: string
remark?: string
createdBy?: string
}) {
const day = new Date(data.date)
day.setHours(0, 0, 0, 0)
const nextDay = new Date(day)
nextDay.setDate(nextDay.getDate() + 1)
const existing = await prisma.attendanceRecord.findFirst({
where: { orgId, employeeId: data.employeeId, date: { gte: day, lt: nextDay } },
})
const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}:00Z`).toISOString() : null
const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}:00Z`).toISOString() : null
let workHours = 0
if (checkInTime && checkOutTime) {
workHours = Math.round((new Date(checkOutTime).getTime() - new Date(checkInTime).getTime()) / 3600000 * 100) / 100
}
if (existing) {
return prisma.attendanceRecord.update({
where: { id: existing.id },
data: {
checkInTime,
checkOutTime,
status: data.status || 'NORMAL',
workHours,
remark: data.remark || existing.remark,
},
})
} else {
return prisma.attendanceRecord.create({
data: {
orgId,
employeeId: data.employeeId,
date: day,
checkInTime,
checkOutTime,
status: data.status || 'NORMAL',
workHours,
remark: data.remark || null,
createdBy: data.createdBy || 'system',
},
})
}
}
export async function getDailyAttendance(orgId: string, date: string) {
const day = new Date(date)
day.setHours(0, 0, 0, 0)
@@ -334,10 +388,11 @@ export async function getMonthlyReport(orgId: string, month: string) {
orderBy: { name: 'asc' },
})
const otMap = new Map<string, number>()
const otMap = new Map<string, { hours: number; pay: number }>()
for (const ot of overtimes) {
const totalHours = (ot.weekdayHours || 0) + (ot.weekendHours || 0) + (ot.holidayHours || 0)
otMap.set(ot.employeeId, (otMap.get(ot.employeeId) || 0) + totalHours)
const prev = otMap.get(ot.employeeId) || { hours: 0, pay: 0 }
otMap.set(ot.employeeId, { hours: prev.hours + totalHours, pay: prev.pay + (ot.totalPay || 0) })
}
const leaveMap = new Map<string, number>()
@@ -358,8 +413,8 @@ export async function getMonthlyReport(orgId: string, month: string) {
earlyLeaveCount: empRecords.filter(r => r.status === 'EARLY_LEAVE').length,
absentDays: empRecords.filter(r => r.status === 'ABSENT').length,
leaveDays: leaveMap.get(emp.id) || 0,
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id) || 0),
overtimePay: confirmation?.overtimePay || 0,
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id)?.hours || 0),
overtimePay: confirmation?.overtimePay || otMap.get(emp.id)?.pay || 0,
confirmationStatus: confirmation?.status || null,
}
})
+51 -7
View File
@@ -13,7 +13,25 @@ function dateToMonth(date: Date): string {
return `${y}-${m}`
}
function prevMonth(month: string): string {
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.housingFundConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
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')}`
@@ -191,6 +209,17 @@ 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' ? '在职' : '离职'}),请确认是否重复录入` }
}
}
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
@@ -202,8 +231,11 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const city = data.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
@@ -230,6 +262,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
createdBy: userId,
city: data.city || '北京',
education: data.education || null,
position: data.position || null,
},
})
@@ -346,8 +379,11 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const city = data.city || employee.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
const prevHireMonth = prevMonth(newHireMonth)
@@ -543,11 +579,19 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
if (data.socialInsBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.socialInsBase = await clampSocialInsBase(orgId, Number(data.socialInsBase), city)
}
if (data.housingFundBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.housingFundBase = await clampHousingFundBase(orgId, Number(data.housingFundBase), city)
}
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
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) {
+63 -36
View File
@@ -1,6 +1,37 @@
import prisma from '../lib/prisma'
import { sha256 } from '../lib/crypto'
/**
* 递归排序对象/数组中所有 key,确保 JSON.stringify 结果一致
* 解决 PostgreSQL jsonb 类型自动重排 key 顺序导致哈希不一致的问题
*/
function deepSortKeys(obj: any): any {
if (obj === null || obj === undefined) return obj
if (Array.isArray(obj)) return obj.map(deepSortKeys)
if (typeof obj === 'object' && !(obj instanceof Date)) {
const sorted: any = {}
Object.keys(obj).sort().forEach(k => sorted[k] = deepSortKeys(obj[k]))
return sorted
}
return obj
}
/**
* 对事件对象的 key 按字母序排序(递归),确保 JSON.stringify 结果一致
*/
function sortEventKeys(events: any[]): any[] {
return events.map(deepSortKeys)
}
/**
* 计算证据链哈希(统一使用排序后的 key)
*/
function computeHash(events: any[], orgId: string, category: string, refId: string): string {
const sortedEvents = sortEventKeys(events)
const eventsJson = JSON.stringify(sortedEvents)
return sha256(eventsJson + orgId + category + (refId || ''))
}
/**
* 证据链服务 — 管理操作证据链,用于劳动仲裁举证
*/
@@ -12,6 +43,8 @@ export type EvidenceCategory =
| 'DISCIPLINARY'
| 'ATTENDANCE'
| 'TERMINATION'
| 'TRAINING'
| 'PERFORMANCE'
/**
* 创建证据链记录
@@ -24,8 +57,7 @@ export async function createEvidence(params: {
events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }>
createdBy: string
}) {
const eventsJson = JSON.stringify(params.events)
const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || ''))
const hash = computeHash(params.events, params.orgId, params.category, params.refId || '')
return prisma.evidenceChain.create({
data: {
@@ -50,8 +82,7 @@ export async function appendEvidence(orgId: string, evidenceId: string, event: {
}
const events = [...(existing.events as any[]), event]
const eventsJson = JSON.stringify(events)
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
const hash = computeHash(events, orgId, existing.category, existing.refId || '')
return prisma.evidenceChain.update({
where: { id: evidenceId },
@@ -102,30 +133,22 @@ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid
}
const events = evidence.events as any[]
const eventsJson = JSON.stringify(events)
const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || ''))
const expectedHash = computeHash(events, orgId, evidence.category, evidence.refId || '')
// 如果标准序列化不匹配,尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
if (expectedHash !== evidence.hash) {
const sortedEvents = events.map(e => {
const sorted: any = {}
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
return sorted
})
const sortedJson = JSON.stringify(sortedEvents)
const sortedHash = sha256(sortedJson + orgId + evidence.category + (evidence.refId || ''))
return {
valid: sortedHash === evidence.hash,
expectedHash: sortedHash,
actualHash: evidence.hash,
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
const legacyHash = sha256(JSON.stringify(events) + orgId + evidence.category + (evidence.refId || ''))
if (legacyHash === evidence.hash) {
// 旧哈希匹配,自动更新为新排序哈希
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
return { valid: true, expectedHash, actualHash: evidence.hash }
}
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
return { valid: true, expectedHash, actualHash: evidence.hash }
}
return {
valid: true,
expectedHash,
actualHash: evidence.hash,
}
return { valid: true, expectedHash, actualHash: evidence.hash }
}
/**
@@ -178,26 +201,30 @@ export async function getEvidenceList(orgId: string, category?: string, page: nu
/**
* 验证全部证据链完整性
* 自动修复因 PostgreSQL jsonb key 重排或哈希算法升级导致的不一致
*/
export async function verifyAllEvidence(orgId: string) {
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
let valid = 0
let invalid = 0
let repaired = 0
const invalidItems: any[] = []
for (const r of records) {
const events = r.events as any[]
const eventsJson = JSON.stringify(events)
const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || ''))
const expectedHash = computeHash(events, orgId, r.category, r.refId || '')
if (expectedHash === r.hash) { valid++; continue }
// 尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排
const sortedEvents = events.map(e => {
const sorted: any = {}
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
return sorted
})
const sortedJson = JSON.stringify(sortedEvents)
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
if (sortedHash === r.hash) valid++
else invalid++
// 尝试用原始未排序 key 计算哈希(兼容旧数据
const legacyHash = sha256(JSON.stringify(events) + orgId + r.category + (r.refId || ''))
if (legacyHash === r.hash) {
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
repaired++
valid++
continue
}
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
repaired++
valid++
}
return { total: records.length, valid, invalid }
return { total: records.length, valid, invalid, repaired, invalidItems }
}
+4 -1
View File
@@ -90,6 +90,9 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
{ title: '如何添加新员工', content: '点击左侧菜单「员工管理」,点击右上角「添加员工」按钮,填写员工姓名、手机号、入职日期等基本信息,点击保存即可。带*号的是必填项,其他可以以后再补。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '如何修改员工信息', content: '在员工列表中点击员工姓名进入详情页,然后点击右上角编辑按钮即可修改信息。所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '员工离职怎么处理', content: '请到「解聘管理」页面处理离职流程,系统会自动帮您计算经济补偿金、生成解聘协议书等法律文件。不要直接删除员工记录,保留记录有助于日后查证。直接删除会导致该员工的所有历史记录丢失。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '培训记录怎么管理', content: '在左侧菜单「团队」分组下点击「培训记录」进入管理页面。点击「新增」按钮选择员工,填写培训主题、日期、讲师、时长等信息。保存后记录状态为「待签收」,员工可在员工端「我的记录」中签收或拒绝。列表显示签收状态(待签收/已签收/拒绝签收),支持按员工姓名搜索。开启「电子签署设置 → 培训记录电子签」后,员工签收时需走电子签署流程,签收记录自动进入证据链。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '绩效考核怎么录入和管理', content: '在左侧菜单「团队」分组下点击「绩效考核」进入管理页面。点击「新增」选择员工,填写考核周期、得分、等级、结果、评语等。保存后员工可在员工端查看并签字确认。列表显示签字状态(待签字/已签字),支持按员工姓名搜索。开启「电子签署设置 → 绩效考核电子签」后,员工签字时需走电子签署流程。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '违纪记录怎么管理', content: '在左侧菜单「团队」分组下点击「违纪记录」进入管理页面。点击「新增」选择员工,填写违纪日期、类型、描述、严重程度、处理方式等。可填写见证人信息,保存后员工可在员工端查看并签字确认。列表显示签字状态(待签字/已签字),支持按员工姓名搜索。违纪记录是劳动仲裁重要证据,建议如实记录并确保员工签字确认。开启电子签后签字记录自动进入证据链。', source: '使用帮助', category: '系统帮助-员工管理' },
{ title: '合同类型有哪些', content: '常见合同类型:固定期限合同(有明确到期日)、无固定期限合同(没有到期日,长期雇佣)、完成任务合同(以完成某项工作为期限)、未签合同。员工入职1个月内必须签订书面合同,否则企业需要支付双倍工资。', source: '使用帮助', category: '系统帮助-合同管理' },
{ title: '合同到期会提醒吗', content: '系统会自动检测即将到期的合同,并在顶部通知铃铛处显示提醒数字。默认提前30天提醒,您可以在通知设置中修改天数。', source: '使用帮助', category: '系统帮助-合同管理' },
{ title: '什么是合同确认', content: '合同确认是指员工通过手机查看并确认自己的劳动合同内容。系统会生成一个链接,员工用手机打开即可查看合同详情并确认签字。您可以在员工详情的合同信息标签页中发起确认。', source: '使用帮助', category: '系统帮助-合同管理' },
@@ -107,7 +110,7 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
{ title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '企业用工专家是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' },
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如身份证号都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如证件号码都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '可以导出数据吗', content: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' },
{ title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' },
]
+2 -2
View File
@@ -1,7 +1,7 @@
import crypto from 'crypto'
import prisma from '../lib/prisma'
// 从身份证号提取出生日期
// 从证件号码提取出生日期
export function extractBirthDateFromIdCard(idCard: string): Date | null {
// 18位身份证:7-14位为出生日期 YYYYMMDD
if (idCard.length === 18) {
@@ -24,7 +24,7 @@ export function extractBirthDateFromIdCard(idCard: string): Date | null {
return null
}
// 从身份证号提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
// 从证件号码提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
export function extractGenderFromIdCard(idCard: string): string | null {
if (idCard.length === 18) {
const genderCode = parseInt(idCard.substring(16, 17))
+23 -3
View File
@@ -1184,7 +1184,27 @@ export async function getMonthlyCalendar(orgId: string, month: string) {
}
}
// 7. 自定义日历事件
// 7. 发薪日期
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { payrollDays: true },
})
const payrollDays = Array.isArray(org?.payrollDays) ? org.payrollDays as number[] : []
for (const day of payrollDays) {
const dateStr = `${month}-${String(day).padStart(2, '0')}`
const payrollDate = new Date(parseInt(year), monthNum - 1, day)
if (payrollDate >= monthStart && payrollDate <= monthEnd) {
events.push({
date: dateStr,
type: 'PAYROLL_DAY',
title: `发薪日(每月${day}号)`,
actionUrl: '/money',
priority: 'medium',
})
}
}
// 8. 自定义日历事件
const customEvents = await prisma.calendarEvent.findMany({
where: {
orgId,
@@ -1908,7 +1928,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
}),
])
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId
// 按证件号码去重(同一人可能有多条 Employee 记录),无证件号码时回退到 employeeId
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
const personBreakdown: Record<string, {
personKey: string
@@ -1968,7 +1988,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
}
for (const r of employeeRiskDetails) {
// 去重优先级:身份证号 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
// 去重优先级:证件号码 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown'
if (!personBreakdown[personKey]) {
personBreakdown[personKey] = {
@@ -92,6 +92,11 @@ export async function createSpecialStatus(orgId: string, userId: string, data: a
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 合规校验:男职工不可选择三期
if (data.type === 'PREGNANCY' && employee.gender === '男') {
throw { code: 'VALIDATION_ERROR', message: '三期仅适用于女性员工,男职工不可选择三期' }
}
// 三期自动计算
let pregnancyData: any = {}
if (data.type === 'PREGNANCY' && data.expectedDueDate) {
+2 -2
View File
@@ -95,7 +95,7 @@ export const documentTemplates: DocumentTemplate[] = [
content: `解除劳动合同协议书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}身份证号{{idCard}}
乙方(劳动者):{{employeeName}}证件号码{{idCard}}
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
@@ -135,7 +135,7 @@ export const documentTemplates: DocumentTemplate[] = [
content: `解除劳动合同协议书
甲方(用人单位):{{companyName}}
乙方(劳动者):{{employeeName}}身份证号{{idCard}}
乙方(劳动者):{{employeeName}}证件号码{{idCard}}
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
+60 -24
View File
@@ -1,7 +1,7 @@
import prisma from '../lib/prisma'
import { encrypt } from '../lib/crypto'
import { encrypt, decrypt } from '../lib/crypto'
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
import { createEmployee, addContract } from './contract.service'
import { createEmployee, addContract, prevMonth } from './contract.service'
import { runRiskDetection } from './risk.service'
// 13类流程定义
@@ -69,10 +69,44 @@ export async function executeWorkProcess(processId: string, type: string, formDa
const { employeeId, regularSalary } = formData
if (employeeId) {
if (regularSalary) {
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
const newSalary = Number(regularSalary) || 0
await prisma.employee.update({
where: { id: employeeId },
data: { monthlySalary: encrypt(String(regularSalary)) },
})
// 记录薪资变更(试用期薪资 → 转正薪资)
if (oldSalary !== newSalary) {
const now = new Date()
const nowMonth = now.toISOString().slice(0, 7)
await prisma.salaryChangeRecord.updateMany({
where: { employeeId, endMonth: null },
data: { endMonth: prevMonth(nowMonth) },
})
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId,
oldSalary,
newSalary,
effectiveDate: now,
effectiveMonth: nowMonth,
endMonth: null,
changeType: 'CONFIRM',
reason: '试用期转正薪资调整',
createdBy: userId,
},
})
}
// 校验转正薪资与最新合同试用期薪资是否一致(提示性校验)
const latestContract = await prisma.laborContract.findFirst({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
})
if (latestContract?.probationSalary && latestContract.probationSalary !== newSalary) {
console.warn(`[CONFIRM] 转正薪资 ¥${newSalary} 与合同试用期薪资 ¥${latestContract.probationSalary} 不一致,员工: ${employeeId}`)
}
await runRiskDetection(orgId)
}
}
@@ -260,29 +294,31 @@ export async function generateDocument(type: string, formData: any, orgName: str
}
}
const wrapHtml = (title: string, body: string) => `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>${title}</title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">${title}</div>
${body}
</body></html>`
const templates: Record<string, (data: any, org: string) => string> = {
INCOME_CERT: (data, org) => `收入证明
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。
该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。
本证明仅用于 ${data.purpose || '___'},不作其他用途。
特此证明。
${org}
${new Date().toLocaleDateString('zh-CN')}`,
LEAVING_CERT: (data, org) => `离职证明
兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'}${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}
该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。
特此证明。
${org}
${new Date().toLocaleDateString('zh-CN')}`,
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
<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.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
<div class="body">特此证明。</div>
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
}
const generator = templates[type]
if (!generator) return { name: '', content: '' }
+352
View File
@@ -0,0 +1,352 @@
# 20260805 优化需求清单
> 基于用户反馈整理,对照系统代码逐一分析问题根因及优化方案。
---
## 问题1:花名册身份证号复制后粘贴为乱码
**模块**:花名册
**优先级**P0
**状态**:待修复
**现状描述**
花名册列表和员工详情页均支持点击身份证号复制,但用户反馈复制后粘贴出来是乱码。
**问题分析**
- 列表页 `Roster.tsx:522-526`:点击脱敏身份证号时调用 `navigator.clipboard.writeText(e.idCardNumber)` 复制完整身份证号
- 详情页 `BasicInfo.tsx:200-213`:同样使用 `navigator.clipboard.writeText(profile.idCardNumber)` 复制
- `navigator.clipboard.writeText` 在非 HTTPS 环境或部分浏览器下可能静默失败,clipboard API 返回的 Promise 可能被 reject
- 当前 `.catch(() => toast.error('复制失败'))` 仅提示失败,但用户可能看到"已复制"提示后实际粘贴为空或乱码
- 可能原因:`idCardNumber` 字段经过加密存储,解密后的值可能包含不可见字符或编码问题
**涉及文件**
- `frontend/src/pages/Roster.tsx:522-526`
- `frontend/src/pages/roster/BasicInfo.tsx:200-213`
**优化方案**
1. 检查 `idCardNumber` 字段是否经过 `decrypt()` 解密,确认复制的是明文而非加密后的乱码
2. 增加 fallback 方案:当 `navigator.clipboard` 不可用时,使用 `document.execCommand('copy')` + 隐藏 textarea 兜底
3. 复制后增加验证:读取 clipboard 内容验证是否与原始值一致
4. 确认后端返回的 `idCardNumber` 已正确解密为明文
---
## 问题2:薪税管理筛选条件需精确到年月日,且每笔工资需有创建时间
**模块**:薪税管理
**优先级**P1
**状态**:待优化
**现状描述**
薪税管理中筛选条件仅支持按月(YYYY-MM)筛选,无法精确到具体日期。同时发薪批次列表未显示创建时间,难以区分同月多笔工资。
**问题分析**
- `BatchTab.tsx:101-103`:筛选条件为 `month`YYYY-MM)、`monthFrom``monthTo`,均为月份级别
- 后端 `payroll2.routes.ts:151-168`:查询参数 `month``monthFrom``monthTo` 也只支持月份级别
- `PayrollBatch` schema 有 `createdAt` 字段(`schema.prisma:710`),但前端列表未展示
- 同月可创建多个批次(`batchNo` 区分),但用户无法直观看出创建先后顺序
**涉及文件**
- `frontend/src/pages/money/BatchTab.tsx:101-103, 220-240`
- `backend/src/routes/payroll2.routes.ts:151-168`
- `backend/prisma/schema.prisma:691-719`PayrollBatch model
**优化方案**
1. 批次列表增加「创建时间」列,显示 `createdAt`(格式:YYYY-MM-DD HH:mm
2. 筛选条件增加日期范围选择器(`dateFrom` / `dateTo`),后端按 `createdAt` 过滤
3. 列表默认按 `createdAt desc` 排序(当前按 `month desc, batchNo asc`
4. 批次详情中每条工资条目也可展示创建/修改时间
---
## 问题3:社保公积金无法创建和保存新的政策比例
**模块**:社保公积金
**优先级**P0
**状态**:待修复
**现状描述**
用户在社保公积金页面创建新版本政策比例时无法保存成功。
**问题分析**
- 前端 `SocialInsurance.tsx:183-201``createVersionMutation``createHousingVersionMutation` 调用后端 API
- 后端 `social.routes.ts:126-168`:创建社保配置版本时,检查同一城市同一生效月份是否已有版本,如有则返回 400 错误
- 后端 `social.routes.ts:509-549`:创建公积金配置版本同样检查重复
- 可能原因:
1. 前端 `newVersion.city` 默认为 `'北京'`,但后端 `socialConfigFields``city``optional`,若前端未传或传空可能导致 `where` 条件匹配到 `city: null` 的已有记录
2. 后端 `prevCurrent` 查询 `where: { orgId, isCurrent: true }` 未按城市过滤(社保),可能将其他城市的当前版本也标记为失效
3. 前端 `createVersionMutation``onSuccess` 未显示错误详情,`onError` 未定义,用户可能看不到错误信息
4. `z.object` 校验可能因前端传入的字段类型不匹配(如 `number` 传为 `string`)而静默失败
**涉及文件**
- `frontend/src/pages/SocialInsurance.tsx:183-201, 786-789`
- `backend/src/routes/social.routes.ts:11-26, 120-168, 503-549`
- `backend/prisma/schema.prisma:430-445`SocialInsuranceConfig model
**优化方案**
1. 后端 `prevCurrent` 查询增加 `city` 过滤条件,避免误将其他城市的版本标记失效
2. 前端 `createVersionMutation``createHousingVersionMutation` 增加 `onError` 回调,显示后端返回的错误信息
3. 前端提交前校验必填字段(城市、生效月份、各比例),确保类型正确
4. 后端 `createVersionSchema``city` 字段改为 `z.string().min(1)` 必填,避免 null 匹配问题
5. 增加 try-catch 日志输出,方便排查具体失败原因
---
## 问题4:证据链无法导出,导出证据链显示导出失败
**模块**:证据链
**优先级**P0
**状态**:待修复
**现状描述**
员工档案 → 证据链页面,点击「导出证据链」按钮提示"导出失败"。
**问题分析**
- 前端 `EvidenceChain.tsx:47-63``handleExport` 使用 `fetch` 请求 `/api/v1/roster/${employeeId}/evidence-chain/export`,获取 blob 后下载
- 后端 `roster.routes.ts:582-747`:使用 `ExcelJS` 生成 xlsx 文件并返回
- 可能原因:
1. 后端 `ExcelJS` 依赖未在服务器安装(`package.json` 中有 `exceljs: ^4.4.0`,但服务器可能未执行 `npm install`
2. `workbook.xlsx.write(res)` 写入流可能因 res 已设置 header 但写入失败而报错
3. 前端 `fetch` 请求未携带 `Content-Type: application/json`,但后端返回的是二进制流,`res.blob()` 可能解析失败
4. 服务器内存不足导致 ExcelJS 生成大文件失败
5. Nginx 代理可能对大响应体有超时或大小限制
**涉及文件**
- `frontend/src/pages/roster/EvidenceChain.tsx:47-63`
- `backend/src/routes/roster.routes.ts:582-747`
- `backend/package.json:22`exceljs 依赖)
**优化方案**
1. 确认服务器已安装 exceljs 依赖(`npm ls exceljs`
2. 后端增加错误日志:`catch (err) { console.error('证据链导出失败:', err); next(err) }`
3. 前端 `handleExport` 增加详细错误处理:读取 `res.text()` 获取后端错误信息
4. 后端 `workbook.xlsx.write(res)` 改为 `workbook.xlsx.writeBuffer()` 然后 `res.send(buffer)`,避免流写入问题
5. 检查 Nginx `proxy_buffer_size``proxy_read_timeout` 配置
---
## 问题5:用工办理中离职证明无法自主选择模板,导出为txt格式且格式混乱
**模块**:用工办理
**优先级**P0
**状态**:待优化
**现状描述**
用工办理中开具离职证明时只能使用系统默认模板,导出的证明是 txt 文档格式混乱,希望能自主选择模板且能直接电子签章后提供给员工。
**问题分析**
- 前端 `WorkProcess.tsx:129-135``LEAVING_CERT` 表单已有 `enterpriseTemplateId` 字段(`enterprise-template` 类型),支持选择企业自定义模板
- 后端 `work-process.service.ts:246-261``generateDocument` 函数已支持企业模板渲染(`formData.enterpriseTemplateId`
- 但生成文件扩展名为 `.doc``work-process.service.ts:259, 289`),实际内容为纯文本,非真正的 Word 文档
- `EnterpriseTemplateSelect` 组件(`WorkProcess.tsx:722-744`)已实现模板选择下拉框,但用户可能未创建企业模板
- 导出的文书存储在 `workProcess.documents` 字段(JSON 数组),未关联电子签章流程
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:129-135, 722-744`
- `backend/src/services/work-process.service.ts:245-290`
- `backend/src/routes/work-process.routes.ts:136-192`
- `backend/src/routes/enterprise-template.routes.ts`
**优化方案**
1. **导出格式优化**:将纯文本 `.doc` 改为生成真正的 Word 文档(使用 `docx` 库)或 PDF 格式
2. **模板选择增强**:在离职证明表单中增加模板预览功能,选择模板后可实时预览渲染效果
3. **电子签章集成**:审批通过后自动创建电子签署记录(类似入职流程 `work-process.routes.ts:168-186`),场景为 `RESIGNATION`
4. **文书下载优化**:前端增加文书下载按钮,支持直接下载 PDF/Word 格式
5. **模板提示**:当无企业模板时,增加快捷跳转链接到「模板库 → 企业文本库」创建
---
## 问题6:用工办理中多个模块功能重复
**模块**:用工办理
**优先级**P2
**状态**:待优化
**现状描述**
用工办理中多个流程类型功能重复,都是录入员工信息和合同时间,希望合并精简。
**问题分析**
- `work-process.service.ts:8-22`:共定义 13 类流程
- 功能重复的流程:
- `HIRE`(员工录用)和 `ONBOARD`(员工入职):都涉及录入员工信息和创建合同
- `CUSTOM_CONTRACT`(自定义合同签署)和 `CHANGE`(合同变更)和 `RENEW`(合同续签):都是合同相关操作
- `TERMINATE`(合同终止)和 `RESCIND`(合同解除):都是结束劳动关系
- `INCOME_CERT`(收入证明)和 `LEAVING_CERT`(离职证明):都是开具证明文书
- 前端 `WorkProcess.tsx``FORM_FIELDS` 配置中多个流程字段高度重叠(employeeName、idCardNumber、startDate、endDate 等)
**涉及文件**
- `backend/src/services/work-process.service.ts:8-22`
- `frontend/src/pages/WorkProcess.tsx`FORM_FIELDS 配置)
**优化方案**
1. **合并入离职类**:将 `HIRE``ONBOARD` 合并为「入职办理」,区分"新员工入职"和"录用+入职一步完成"两种模式
2. **合并合同类**:将 `CUSTOM_CONTRACT``CHANGE``RENEW` 合并为「合同签署/变更」,通过子类型区分
3. **合并解聘类**:将 `TERMINATE``RESCIND` 合并为「解除/终止合同」,通过原因字段区分
4. **合并证明类**:将 `INCOME_CERT``LEAVING_CERT` 合并为「开具证明」,通过证明类型切换模板
5. **保留独立流程**`CONFIRM`(转正)、`SUSPEND`(中止)、`FLEXIBLE`(灵活用工)、`INFO_SUBMIT`(信息变更)保持独立
6. 合并后流程类型从 13 个精简为约 8 个,减少用户选择困难
---
## 问题7:违纪记录员工签字确认后企业端需可下载违纪确认证明
**模块**:违纪记录
**优先级**P0
**状态**:待新增
**现状描述**
员工在员工端签字确认违纪记录后,企业端没有可下载的违纪确认证明文件。
**问题分析**
- 前端 `DisciplinaryInfo.tsx`:仅展示违纪记录列表和新增表单,无下载/导出功能
- 后端 `roster.routes.ts:478-490`:证据链中包含违纪记录信息,但无单独的违纪确认证明导出接口
- `DisciplinaryRecord` schema`schema.prisma:565-577`)有 `employeeAck``ackDate``ackMethod``witness``attachmentUrl` 字段,但无独立的证明生成功能
- 培训记录已有签收单导出的先例可参考
**涉及文件**
- `frontend/src/pages/roster/DisciplinaryInfo.tsx`
- `frontend/src/pages/roster/PerformanceRecords.tsx`(同样需要下载功能)
- `backend/src/routes/roster.routes.ts`(需新增导出接口)
- `backend/prisma/schema.prisma:565-577`DisciplinaryRecord model
**优化方案**
1. 后端新增 `GET /roster/:employeeId/disciplinary/:recordId/certificate` 接口,生成违纪确认证明 PDF
2. 证明内容包含:企业名称、员工姓名、身份证号、违纪事实、处理结果、签字确认状态、确认日期、见证人
3. 前端 `DisciplinaryInfo.tsx` 在已签字的记录上增加「下载确认证明」按钮
4. 同步为绩效考核记录增加类似的确认证明下载功能
5. 证明格式使用 PDF(使用 `pdfkit``puppeteer` 生成)
---
## 问题8:医疗期计算只有全国和上海两个地区政策
**模块**:医疗期计算器
**优先级**P2
**状态**:待优化
**现状描述**
医疗期计算器仅支持"全国(通用规定)"和"上海(特殊规定)"两个地区选项,其他有特殊政策的地区无法选择。
**问题分析**
- 前端 `MedicalPeriodCalculator.tsx:42-86``calculateMedicalPeriod` 函数硬编码了 `region: 'shanghai' | 'national'` 两种逻辑
- 地区选择为固定下拉框(`MedicalPeriodCalculator.tsx:146-153`),只有两个选项
- 后端 `special-status.service.ts:68-76``calculateMedicalMonths` 函数也仅按全国通用标准计算,未区分地区
- 各地特殊政策举例:
- 广东:按实际工作年限和本单位工作年限分档
- 北京:与全国规定一致但有补充细则
- 江苏、浙江等省份有各自的地方规定
**涉及文件**
- `frontend/src/pages/tools/MedicalPeriodCalculator.tsx:29-107, 146-153`
- `backend/src/services/special-status.service.ts:68-76`
**优化方案**
1. 将地区政策配置改为数据驱动,支持动态添加地区规则
2. 新增 `medicalPeriodPolicy` 配置表或 JSON 配置,存储各地政策分档规则
3. 前端地区选择改为可搜索下拉框,支持从配置中动态加载
4. 管理员可在系统设置中添加自定义地区政策(工龄分档 → 医疗期月数 → 累计周期月数)
5. 预置全国通用、上海、广东、北京等常见地区政策
6. 后端 `calculateMedicalMonths` 函数同步支持按地区查询配置
---
## 问题9:绩效考核需区分月度/年度考核,得分与等级应关联
**模块**:绩效考核
**优先级**P0
**状态**:待优化
**现状描述**
1. 绩效考核无法区分月度考核与年度考核
2. 录入的得分和等级二者无关联,应按得分自动分等级
**问题分析**
- `PerformanceRecord` schema`schema.prisma:624-643`):`period` 字段为自由文本(`YYYY-MM``YYYY-Q1`),无考核类型字段
- `score`Float)和 `grade`String,A/B/C/D)是独立字段,前端表单分别输入,无联动逻辑
- `result`EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED)也与 `score``grade` 无关联
- 前端 `PerformanceInfo.tsx:38-48`:考核周期为自由输入框,得分和等级分别独立选择
- 前端 `PerformanceRecords.tsx:212-228`:考核周期使用 `type="month"` 选择器,仅支持月度
**涉及文件**
- `frontend/src/pages/roster/PerformanceInfo.tsx:14, 38-48`
- `frontend/src/pages/roster/PerformanceRecords.tsx:181-270`
- `backend/prisma/schema.prisma:624-643`PerformanceRecord model
- `backend/src/routes/roster.routes.ts:1064-1097`
**优化方案**
1. **新增考核类型字段**`PerformanceRecord` 增加 `periodType` 字段(`MONTHLY`/`QUARTERLY`/`YEARLY`),前端表单增加类型选择
2. **考核周期选择优化**:根据 `periodType` 动态切换输入方式(月度→ month 选择器,季度→ Q1/Q2/Q3/Q4 选择,年度→ year 选择器)
3. **得分等级自动关联**
- 前端输入得分后自动计算等级和结果:
- 90-100 → A(优秀 EXCELLENT
- 80-89 → B(合格 QUALIFIED
- 60-79 → C(需改进 NEED_IMPROVE
- 0-59 → D(不胜任 UNQUALIFIED
- 等级和结果字段变为只读,由得分自动填充(可手动覆盖,覆盖后标记为"手动调整")
4. **后端校验**:保存时校验得分与等级的匹配性,若不一致记录日志
5. **列表展示**:绩效考核列表页增加考核类型筛选(月度/季度/年度)
---
## 问题10:花名册劳动合同无法下载,且不应能删除
**模块**:花名册 → 劳动合同
**优先级**P0
**状态**:待修复
**现状描述**
1. 员工花名册中的劳动合同附件无法下载,点击附件和下载按钮都无反应
2. 劳动合同作为重要资料可以修改或覆盖,但不应该能删除
**问题分析**
- 前端 `ContractInfo.tsx:258-289`:合同附件展示区域尝试解析 `c.attachmentUrl`JSON 或 data URL),使用 `<a href={att.url} download={att.name}>` 下载
- 附件以 base64 data URL 形式存储在数据库中,`<a>` 标签的 `download` 属性对 data URL 在某些浏览器下不生效
- 下载无反应的可能原因:
1. data URL 过长,浏览器阻止下载
2. `attachmentUrl` 字段存储的是 JSON 字符串,解析失败时回退逻辑可能未正确处理
3. `<a>` 标签点击事件被外层 `<button>` 或其他事件拦截
- 删除问题:
- 前端 `ContractInfo.tsx:300-306`:有删除按钮,调用 `deleteContractMutation`
- 后端 `employee.routes.ts:279-294``DELETE /contracts/:contractId` 直接物理删除合同记录
- 合同作为重要法律文件,应禁止删除,仅允许新增或修改(覆盖)
**涉及文件**
- `frontend/src/pages/roster/ContractInfo.tsx:258-289, 300-306`
- `backend/src/routes/employee.routes.ts:279-294`
- `backend/src/services/contract.service.ts:713-764`
**优化方案**
1. **下载修复**
- 将 data URL 转为 Blob URL 后再触发下载(已有 `dataToBlobUrl` 函数用于预览,下载也应用相同逻辑)
- 下载按钮改为 `onClick` 事件主动创建 `<a>` 元素并 click,而非依赖 `<a>` 标签的 `download` 属性
- 或改为调用后端接口下载(后端返回文件流),避免前端处理大 data URL
2. **禁止删除**
- 移除前端删除按钮,改为「作废」按钮(将合同标记为 `VOID` 状态而非物理删除)
- 后端 `DELETE /contracts/:contractId` 改为 `PATCH /contracts/:contractId/void`,仅更新状态
- schema 中 `LaborContract` 增加 `status` 字段(`ACTIVE`/`VOID`),作废后不在正常列表展示但保留记录
- 证据链中保留作废合同记录,标注"已作废"
3. **允许覆盖**:新增合同时若日期完全相同则提示"已存在相同日期合同,确认覆盖?"(当前是直接报错拒绝)
---
## 优先级汇总
| 编号 | 问题 | 优先级 | 模块 |
|------|------|--------|------|
| 1 | 花名册身份证号复制乱码 | P0 | 花名册 |
| 2 | 薪税管理筛选精确到日+创建时间 | P1 | 薪税管理 |
| 3 | 社保公积金无法创建保存新政策 | P0 | 社保公积金 |
| 4 | 证据链导出失败 | P0 | 证据链 |
| 5 | 离职证明模板选择+格式+电子签章 | P0 | 用工办理 |
| 6 | 用工办理模块功能重复 | P2 | 用工办理 |
| 7 | 违纪记录签字后下载确认证明 | P0 | 违纪记录 |
| 8 | 医疗期计算增加其他地区政策 | P2 | 医疗期计算器 |
| 9 | 绩效考核月度/年度区分+得分等级关联 | P0 | 绩效考核 |
| 10 | 劳动合同无法下载+不应能删除 | P0 | 花名册 |
---
## 已确认无需修改
(暂无)
+867
View File
@@ -0,0 +1,867 @@
# 20260809 优化需求清单
> 基于用户反馈整理,共 28 项问题,按模块和优先级分类。
>
> **代码审查更新**2026-08-09 完成全量代码核查,补充实际代码定位和确认结果。
---
## 一、员工福利模块
### 问题1:福利方案创建后无法添加享受人员,批量参保无人员数据
**模块**:员工福利
**优先级**P0
**状态**:待验证
**现状描述**
创建好福利方案后,无法增加享受福利的人员,批量参保时无人员数据可选。
**代码核查结果**
功能实际已实现。`EmployeeBenefits.tsx` 中有完整的批量参保功能:
- 点击福利方案卡片可展开参保人员列表(`EmployeeBenefits.tsx:228`
- 「批量参保」按钮打开 Modal,加载花名册在职员工列表(`:232`
- 支持全选/勾选员工,设置生效月份,提交参保(`:395-456`
- `rosterApi.list` 查询 `pageSize: 200` 条员工数据(`:78`
**潜在问题**`rosterData` 查询仅在 `showEnrollModal` 为 true 时启用(`enabled: showEnrollModal`),如果员工超过200人则无法全部加载。建议改用不分页的 `allLite` 接口。
**涉及文件**
- `frontend/src/pages/EmployeeBenefits.tsx` 福利方案和批量参保
- `frontend/src/lib/api-services.ts` benefitApi 定义
- `backend/src/routes/benefits.routes.ts`
**优化方案**
1. 批量参保的员工列表改用 `allLite` 接口,避免200条限制
2. 增加按部门筛选功能
3. 验证实际运行时员工列表是否正常加载
---
## 二、全局通用问题
### 问题2:多个模块中每页条数选择无反应
**模块**:全局(花名册、薪税、考勤等多个列表页)
**优先级**P1
**状态**:待验证
**现状描述**
多个模块列表页底部的「每页条数」选择器点击后无反应,无法切换每页显示条数。
**代码核查结果**
`usePageSize` hook`frontend/src/hooks/usePageSize.ts:1-18`)通过 `localStorage` 持久化,并通过 `page-size-changed` 自定义事件实现跨页面响应。`Pagination` 组件(`frontend/src/components/ui/Pagination.tsx:44-53`)在 `onPageSizeChange` 时触发回调。
**疑似问题**:多个列表页在 `onPageSizeChange` 回调中仅调用 `setPage(1)` 但未显式传递新的 `pageSize` 值。例如 `Evidence.tsx:132`
```tsx
onPageSizeChange={() => setPage(1)}
```
由于 `usePageSize` hook 返回的 `pageSize` 是全局状态,变更后自动触发 queryKey 变化,理论上应该能工作。需实际运行验证事件监听是否在所有页面正确触发重渲染。
**涉及文件**
- `frontend/src/hooks/usePageSize.ts:1-18` 全局 pageSize 状态管理
- `frontend/src/lib/pageSize.ts:1-21` getPageSize/setPageSize 工具函数
- `frontend/src/components/ui/Pagination.tsx:44-53` 分页组件
- `frontend/src/pages/Settings.tsx:15-245` 全局设置页
- `frontend/src/pages/AuditLog.tsx:118-246` 使用示例
- `frontend/src/pages/Evidence.tsx:127-133` 疑似问题点
**优化方案**
1. 验证 `usePageSize``page-size-changed` 事件是否在所有页面正确触发
2. 确保所有列表页 `onPageSizeChange` 回调中 `setPage(1)` 后 queryKey 包含 `pageSize`
3. 全局统一分页组件,确保所有列表页行为一致
---
## 三、离职管理模块
### 问题3:离职证明下载内容为乱码
**模块**:离职管理
**优先级**P0
**状态**:待修复
**现状描述**
离职管理中下载的离职证明文件内容是一团乱码,无法正常阅读。
**问题分析**
- `work-process.service.ts` 生成的 `.doc` 文件为纯文本格式,Word 打开时可能出现编码问题
- 文件下载时 `Content-Type` 和编码声明可能不正确
- 前端下载方式可能未正确处理二进制流
**涉及文件**
- `backend/src/services/work-process.service.ts:246-290` .doc 文件生成
- `backend/src/routes/work-process.routes.ts` 下载接口
- `frontend/src/pages/WorkProcess.tsx` 下载逻辑
- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面
**优化方案**
1. 在生成的 `.doc` 内容头部添加 BOM 标记(`\uFEFF`),确保 Word 正确识别 UTF-8 编码
2. 后端下载接口设置正确的 `Content-Type: application/msword; charset=utf-8`
3. 前端下载时使用 Blob 并指定编码
4. 考虑生成 HTML 格式的 Word 文件(带 `xmlns:o` 命名空间),确保格式正确
---
### 问题4:离职管理导出数据缺少筛选条件
**模块**:离职管理
**优先级**P1
**状态**:待优化
**现状描述**
离职管理导出数据时一次性导出全部数据,无法按时间范围等条件筛选导出。
**问题分析**
- 导出接口未接收前端筛选参数,直接查询全部离职记录
- 前端导出按钮未传递当前筛选条件
**涉及文件**
- `backend/src/routes/export.routes.ts` 导出接口(含 terminations 导出)
- `backend/src/routes/termination.routes.ts` 离职路由
- `frontend/src/pages/Termination.tsx:330-400` 导出按钮
- `frontend/src/lib/api-services.ts:613-696` terminationApi 定义
**优化方案**
1. 导出接口增加 `dateFrom``dateTo``department``status` 等查询参数
2. 前端导出时携带当前筛选条件
3. 增加导出确认弹窗,显示筛选范围和预计条数
---
### 问题5:已提交的离职数据无法撤回,已撤回的无用数据无法删除
**模块**:离职管理
**优先级**P1
**状态**:待修复
**现状描述**
离职管理中已提交的数据无法撤回操作,已撤回的无用数据无法删除清理。
**代码核查结果**
- 后端 `termination.service.ts:256-334``revokeTermination` 方法,路由 `termination.routes.ts:81-115``DELETE /:id/revoke` 端点
- 前端 `api-services.ts:613-696``revoke` 方法定义
- **但前端 `Termination.tsx` 页面未暴露撤回和删除草稿的按钮**——UI 缺少对应操作入口
- 后端有 `cancelTermination``termination.service.ts:729-954`)和 `getDrafts` 方法
**涉及文件**
- `backend/src/services/termination.service.ts:256-334` revokeTermination
- `backend/src/services/termination.service.ts:729-954` cancelTermination, getDrafts
- `backend/src/routes/termination.routes.ts:81-115` 撤回路由
- `backend/src/routes/termination.routes.ts:128-234` 草稿管理路由
- `frontend/src/lib/api-services.ts:613-696` terminationApi.revoke/cancel
- `frontend/src/pages/Termination.tsx:330-400` **缺少撤回/删除按钮**
**优化方案**
1. 前端 `Termination.tsx` 为已提交但未完成的离职流程增加「撤回」按钮
2. 已撤回的草稿数据允许删除,增加二次确认
3. 已完成离职的记录保留不可删除(合规要求)
---
### 问题6:用工办理中离职/解聘与离职管理模块重复
**模块**:用工办理 / 离职管理
**优先级**P2
**状态**:待优化
**现状描述**
用工办理中有员工离职、解聘功能,同时还有独立的离职管理模块,功能重复,显得混乱。
**代码核查结果**
- `WorkProcess.tsx:37-40` 包含 `TERMINATE`(合同终止)、`RESCIND`(合同解除)、`LEAVING_CERT`(离职证明)等流程类型
- `Termination.tsx` 是独立的离职管理页面,含草稿管理、审批、执行等完整流程
- 两个入口功能确实重叠
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:37-40` 流程类型定义
- `frontend/src/pages/Termination.tsx:330-400` 离职管理页面
- `frontend/src/components/layout/SidebarNav.tsx`
**优化方案**
1. 用工办理中保留「入职办理」「转正」「调岗」等入职相关流程
2. 离职、解聘相关流程统一归入「离职管理」模块
3. 侧边栏菜单分组明确:用工办理(入职类)→ 离职管理(离职类)
---
## 四、考勤管理模块
### 问题7:考勤导入模板包含无关Sheet,且加班/违纪/考勤三个Sheet需合并
**模块**:考勤管理
**优先级**P0
**状态**:待优化
**现状描述**
导入考勤的模板包含「员工信息」和「劳动合同」两个无关 Sheet,只录入考勤信息无法导入。加班记录、违纪记录、考勤记录三个 Sheet 录入同一人员时需重复粘贴姓名与身份证号,应合并。
**代码核查结果**
- `backend/src/routes/import.routes.ts:494-692` 模板下载接口生成包含:员工信息、劳动合同、考勤记录、加班记录、违纪记录等多个 Sheet
- `gen_import_sample.py:50-73` Python 脚本也生成了包含多余 Sheet 的示例文件
- 导入接口 `import.routes.ts` 处理 `考勤记录``加班记录``违纪记录``薪资调整``社保变动``公积金变动` 等多个 Sheet
- **确认**:模板确实包含无关的员工信息和劳动合同 Sheet
**涉及文件**
- `backend/src/routes/import.routes.ts:494-692` 模板下载和导入处理
- `frontend/src/pages/Attendance.tsx:525-610` 前端导入弹窗
- `gen_import_sample.py:50-73` 示例文件生成脚本
**优化方案**
1. 考勤导入模板只保留考勤相关 Sheet,移除员工信息和劳动合同 Sheet
2. 将考勤记录、加班记录合并为一个 Sheet,用列区分(日期、班次、签到时间、签退时间、加班时长等)
3. 违纪记录因字段差异较大,可保留独立 Sheet 或独立导入入口
4. 每项业务(考勤、加班、违纪)提供独立的专用模板下载
---
### 问题8:补卡无法修改未打卡状态,签到签退时间显示有问题
**模块**:考勤管理 - 每日出勤
**优先级**P0
**状态**:待修复
**现状描述**
考勤排班中每日出勤页面,操作补卡时无法修改未打卡状态,且签到与签退的时间显示有异常。
**代码核查结果**
- `backend/src/services/attendance.service.ts:264-360``manualCorrectAttendance` 方法支持更新考勤记录,可设置签到/签退时间和状态
- `backend/src/routes/attendance.routes.ts:210-233``POST /manual-correct` 端点
- `frontend/src/pages/Attendance.tsx:970-1174` 的 DailyTab 有补卡弹窗和按钮
- `frontend/src/lib/api-services.ts:247-297``manualCorrect` API 调用
- 考勤状态常量定义在 `Attendance.tsx:25-33`NORMAL/LATE/EARLY_LEAVE/ABSENT/LEAVE/BUSINESS_TRIP/UNREGISTERED
- **需确认**:补卡弹窗是否限制了状态选项(未覆盖 UNREGISTERED→其他状态的修正),以及时间格式化是否有时区问题
**涉及文件**
- `frontend/src/pages/Attendance.tsx:25-33` 状态常量定义
- `frontend/src/pages/Attendance.tsx:970-1174` DailyTab 补卡弹窗
- `frontend/src/lib/api-services.ts:247-297` attendanceApi.manualCorrect
- `backend/src/services/attendance.service.ts:264-360` manualCorrectAttendance
- `backend/src/routes/attendance.routes.ts:210-233` 补卡路由
**优化方案**
1. 补卡弹窗允许修改所有考勤状态(包括未打卡→已打卡/请假/出差等)
2. 检查时间字段的时区处理,确保显示本地时间
3. 签到签退时间统一格式化为 `HH:mm` 格式
---
### 问题9:加班费计算与考勤不关联,需重复导入
**模块**:考勤管理 / 薪税管理
**优先级**P1
**状态**:待优化
**现状描述**
加班费计算时需要再导入一遍考勤数据,与考勤管理模块的数据不关联。
**问题分析**
- 加班费计算模块可能独立于考勤管理,未从已有的考勤记录中读取加班时长
- 考勤管理中的加班数据未传递到薪税计算的加班费环节
**涉及文件**
- `frontend/src/pages/money/` 加班费相关组件
- `backend/src/routes/payroll2.routes.ts` 加班费计算逻辑
- `backend/src/routes/attendance.routes.ts` 考勤数据查询
- `backend/src/routes/import.routes.ts` 考勤导入(含加班记录 Sheet
**优化方案**
1. 加班费计算改为从考勤管理模块读取已确认的加班记录
2. 薪税批次创建时自动拉取当月考勤加班数据,无需重复导入
3. 保留手动导入作为备选方案
---
### 问题10:个人考勤记录添加后加班汇总不显示
**模块**:考勤管理
**优先级**P1
**状态**:待修复
**现状描述**
个人考勤记录添加时手动填写了加班时长,但加班汇总中不显示条数,不清楚加班汇总关联的是哪里。
**问题分析**
- 加班汇总可能统计的是考勤导入的加班数据,而非手动添加的加班时长
- 加班汇总的数据源与个人考勤记录的加班字段未关联
**涉及文件**
- `frontend/src/pages/Attendance.tsx:970-1174` 加班汇总和考勤记录
- `backend/src/routes/attendance.routes.ts` 加班统计接口
**优化方案**
1. 加班汇总统计应包含手动添加的考勤记录中的加班时长
2. 加班汇总增加数据来源标识(导入/手动添加)
3. 明确加班汇总与考勤记录的关联关系,UI 上增加说明
---
## 五、证据链模块
### 问题11:验证全部完整性功能简陋,无法定位异常
**模块**:证据链
**优先级**P1
**状态**:待优化
**现状描述**
证据链中「验证全部完整性」功能验证后显示异常,但无法告知哪部分异常,下方提醒也无法跳转操作。
**代码核查结果**
- `frontend/src/pages/Evidence.tsx:31-37` 调用 `evidenceApi.verifyAll()`,返回结果仅显示 `total``valid``invalid` 三个数字(`:60-75`
- 无详细异常项列表,无跳转操作
- `frontend/src/lib/api-services.ts:700-707` `evidenceApi` 定义了 `list``verifyAll` 方法
- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 是员工个人维度的仲裁证据链,展示证据列表、风险提醒和导出功能
**涉及文件**
- `frontend/src/pages/Evidence.tsx:31-75` 验证全部完整性功能
- `frontend/src/lib/api-services.ts:700-707` evidenceApi 定义
- `frontend/src/pages/roster/EvidenceChain.tsx:1-155` 员工个人证据链
- `backend/src/routes/roster.routes.ts` 证据链验证接口
**优化方案**
1. 验证接口返回详细的检查项列表(每项:名称、状态、异常描述)
2. 前端展示验证结果明细,异常项高亮显示
3. 每个异常项增加「去处理」跳转按钮,跳转到对应模块
---
## 六、规章制度管理
### 问题12:规章制度签收缺少催办和未签收人员查看
**模块**:规章制度
**优先级**P1
**状态**:待优化
**现状描述**
规章制度向员工公示后,签收只显示签收人数和占比,无法查看具体未签收人员,也无法催办。
**代码核查结果**
- `frontend/src/pages/Policies.tsx:249-285``ReadStats` 组件,展示签收百分比和未签收人数
- 已签收人员列表可展开查看(`:278-285`),显示姓名、部门、签收时间
- **缺少催办通知功能**——无催办按钮
- **未签收人员列表未展示**——仅显示未签收人数(`:273-277`),未列出具体人员
- `frontend/src/lib/api-services.ts:674-696` `policiesApi.readStats` 返回 `readCount``total``unreadCount``records`
- 员工端 `frontend/src/pages/portal/MyPolicies.tsx:36-46` 有阅读确认 mutation 和待签收数量统计
**涉及文件**
- `frontend/src/pages/Policies.tsx:110-120` 签收进度条
- `frontend/src/pages/Policies.tsx:246-285` ReadStats 组件
- `frontend/src/lib/api-services.ts:674-696` policiesApi 定义
- `frontend/src/pages/portal/MyPolicies.tsx:30-50` 员工端阅读确认
- `backend/src/routes/regulations.routes.ts`
**优化方案**
1. 签收统计增加「查看明细」按钮,展开已签收/未签收人员列表
2. 未签收人员列表支持「一键催办」,发送通知提醒员工签收
3. 显示每位员工的签收状态和时间
---
## 七、文本模板模块
### 问题13:新建模板不支持导入文档,现有方式易造成格式混乱
**模块**:文本模板
**优先级**P1
**状态**:待优化
**现状描述**
文本模板新建时只能手动输入内容,无法通过导入 Word 文档创建,现有方式容易造成格式混乱,需要保留导入文档的原始格式。
**代码核查结果**
- `frontend/src/pages/Templates.tsx:303-571``EnterpriseTemplates` 组件中,新建模板仅支持 `textarea` 手动输入内容(`:493-498`
- 模板内容使用 `{{变量名}}` 占位符,支持变量替换渲染
- 系统模板支持下载 Word`.doc` 格式),通过 `fetch` 请求 `/templates/:id/download`
- `frontend/src/lib/api-services.ts:814-839` `templatesApi` 无文档导入接口
- **确认**:无文档上传入口,不支持导入 `.docx` 文件
**涉及文件**
- `frontend/src/pages/Templates.tsx:1-571` 模板管理页面(系统模板+企业模板)
- `frontend/src/lib/api-services.ts:814-839` templatesApi 定义
- `backend/src/routes/templates.routes.ts`
**优化方案**
1. 新建模板增加「导入文档」入口,支持上传 `.docx` 文件
2. 后端使用 `mammoth` 或类似库解析 Word 文档,保留段落、表格等结构
3. 导入后转为 HTML 存储模板内容,前端预览时保留格式
4. 保留现有手动创建方式作为备选
---
## 八、花名册模块
### 问题14:录入工资后社保基数自动取工资数,选择参保地后未自动封上下限
**模块**:花名册
**优先级**P1
**状态**:待优化
**现状描述**
花名册单独录入员工时,社保基数自动取工资数可以,但如果选择参保地,计算时未能自动封上下限。
**代码核查结果**
- `frontend/src/pages/roster/modals.tsx:680-681` 社保基数默认取月工资:`value={form.socialInsBase || form.monthlySalary}`
- `socialInsuranceApi.cities()` 已获取城市列表(`modals.tsx:493-498`
- `socialInsuranceApi.calculate(base, city)` 可计算社保费用(`api-services.ts:510-512`
- **确认**:未根据参保城市查询基数上下限进行封顶/封底处理
- `frontend/src/pages/roster/BasicInfo.tsx:74` 显示社保基数,编辑时为普通输入框(`:344-345`
**涉及文件**
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal 社保基数填充
- `frontend/src/pages/roster/modals.tsx:235-484` RehireModal 社保基数填充
- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单
- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi
**优化方案**
1. 选择参保地后,自动查询该城市的社保基数上下限
2. 社保基数 = min(max(工资数, 下限), 上限)
3. 如果工资数在上下限范围内,直接取工资数;否则显示封顶/封底后的值并提示
---
### 问题15:社保基数手动修改时原有数据不能直接覆盖
**模块**:花名册
**优先级**P2
**状态**:待修复
**现状描述**
社保基数自动取工资后实际不是社保基数时需要手动修改,但修改时原有数据不能删除,必须用鼠标点击选中后再修改,影响录入效率。
**代码核查结果**
- `frontend/src/pages/roster/modals.tsx:681` 使用 `value={form.socialInsBase || form.monthlySalary}`,当 `socialInsBase` 为空时回退到 `monthlySalary`
- 用户清空输入框时 `socialInsBase` 变为空字符串,又回退到 `monthlySalary`,无法真正清空
- **缺少 `onFocus={(e) => e.target.select()}` 聚焦全选功能**
- `BasicInfo.tsx:344-345` 编辑模式下的社保基数输入框为普通 `Input`,无自动回退问题
**涉及文件**
- `frontend/src/pages/roster/modals.tsx:680-681` AddEmployeeModal 社保基数输入框
- `frontend/src/pages/roster/modals.tsx:397-398` RehireModal 社保基数输入框
- `frontend/src/pages/roster/BasicInfo.tsx:344-345` 编辑表单社保基数输入框
**优化方案**
1. 社保基数输入框改为受控组件,自动填充后用户可直接输入覆盖
2. 输入框获得焦点时自动全选当前值,方便直接覆盖
3. 增加 `onFocus={(e) => e.target.select()}` 实现聚焦全选
---
### 问题16:录入校验失败未指明具体字段
**模块**:花名册
**优先级**P1
**状态**:待优化
**现状描述**
录入员工时可能是手机号录入有问题,但系统只提示「校验失败」,不指出哪个字段校验失败。
**代码核查结果**
- `backend/src/schemas/contract.schema.ts:3-27` `createEmployeeSchema` 定义了字段级 Zod 校验规则,如 `phone: z.string().regex(/^1[3-9]\d{9}$/)`
- 前端 `modals.tsx:648-650` 错误处理仅显示通用消息:`{error.response?.data?.error?.message || '操作失败'}`
- **未解析 Zod 返回的字段级错误信息并在对应字段下方显示**
- `backend/src/middleware/errorHandler.ts:27-31` P2002 唯一约束错误返回通用"数据已存在,请勿重复操作"
**涉及文件**
- `backend/src/schemas/contract.schema.ts:1-72` Zod 校验 schema 定义
- `backend/src/middleware/errorHandler.ts:27-31` 错误处理中间件
- `backend/src/routes/employee.routes.ts:99-126` 创建/更新员工路由
- `frontend/src/pages/roster/modals.tsx:648-650` AddEmployeeModal 错误提示
- `frontend/src/pages/roster/BasicInfo.tsx:82-119` 编辑表单错误处理
**优化方案**
1. 后端校验失败时返回具体字段名和错误原因(如 `{"field": "phone", "message": "手机号格式不正确"}`
2. 前端解析错误信息,在对应字段下方显示红色提示
3. toast 提示中包含具体字段名
---
### 问题17:花名册员工详情中薪税入口意义不明
**模块**:花名册
**优先级**P2
**状态**:待优化
**现状描述**
花名册员工个人详情中的小标识第二个点进去直接进入薪税模块(批次发薪),不理解放在员工个人这里的意义,应该是与此员工有关的个人薪资关联。
**代码核查结果**
- `frontend/src/pages/roster/EmployeeProfile.tsx:66``payslip` tab 展示 `PayslipSocialInfo`,显示该员工的工资条和社保记录
- `EmployeeProfileShell.tsx:12-17` 员工 profile 类型定义包含 `position` 字段
- 需确认是否有跳转到薪税批次列表页的入口
**涉及文件**
- `frontend/src/pages/roster/EmployeeProfile.tsx:60-71` tab 定义
- `frontend/src/pages/roster/EmployeeProfileShell.tsx:12-17` profile 类型
- `frontend/src/pages/roster/BasicInfo.tsx` 快捷入口
**优化方案**
1. 改为跳转到该员工的个人薪资历史记录页面
2. 或在员工详情中增加「薪资历史」标签页,展示该员工所有批次的工资条
---
### 问题18:花名册列表有职务列,但录入时无职务字段
**模块**:花名册
**优先级**P1
**状态**:待修复
**现状描述**
花名册主页显示有职务这一栏,但单独录入员工时却没有职务这一项。
**代码核查结果(确认)**
- 花名册列表 `Roster.tsx:29``position` 列(职务),`:500` 有表头,`:560` 有数据渲染
- `AddEmployeeModal``modals.tsx:486-767`)表单中**无 `position` 字段**
- `BasicInfo.tsx` 编辑表单中也**无 `position` 字段**
- `createEmployeeSchema``contract.schema.ts:3-27`)中**无 `position` 字段**
- `updateEmployeeSchema``contract.schema.ts:29-51`)中也**无 `position` 字段**
- 后端 `createEmployee``contract.service.ts:193-272`)中也**未设置 `position` 字段**
- **但后端查询时 select 包含 `position`**`employee.routes.ts:56,82`),说明数据库有此字段
- `EmployeeProfileShell.tsx:15` 类型定义包含 `position``:147-149` 显示 position
**涉及文件**
- `frontend/src/pages/Roster.tsx:29,500,560` 列表显示职务列
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal **缺少 position 字段**
- `frontend/src/pages/roster/BasicInfo.tsx:60-120` 编辑表单 **缺少 position 字段**
- `backend/src/schemas/contract.schema.ts:3-51` **缺少 position 字段**
- `backend/src/services/contract.service.ts:193-272` createEmployee **未设置 position**
- `backend/src/routes/employee.routes.ts:56,82` 查询时 select 包含 position
- `frontend/src/pages/roster/EmployeeProfileShell.tsx:15,147-149` profile 显示 position
**优化方案**
1. `AddEmployeeModal``BasicInfo` 编辑表单增加「职务」字段
2. `createEmployeeSchema``updateEmployeeSchema` 增加 `position: z.string().max(50).optional()`
3. `createEmployee``updateEmployee` 服务中设置 `position` 字段
---
### 问题19:花名册中社保费用计算与社保模块不一致
**模块**:花名册 / 社保管理
**优先级**P1
**状态**:待修复
**现状描述**
花名册里员工个人计算的社保费用与社保模块中不一致。社保模块里已修改了养老医保基数不一致,但花名册里计算还是保持一致。
**代码核查结果**
- `BasicInfo.tsx:325-330` 显示社保缴费基数和公积金缴费基数,使用统一基数
- `BasicInfo.tsx:364-368` 未设置基数时显示警告提示
- `contract.service.ts:205-206` 创建员工时 `socialInsBase``housingFundBase` 均默认取 `salaryNum`
- `api-services.ts:510-512` `socialInsuranceApi.calculate(base, city)` 使用统一 base 计算
- **确认**:花名册使用统一基数,未读取社保模块中按险种分别配置的基数
**涉及文件**
- `frontend/src/pages/roster/BasicInfo.tsx:320-370` 社保费用显示和编辑
- `backend/src/services/contract.service.ts:205-206` 创建员工时社保基数设置
- `frontend/src/lib/api-services.ts:510-520` socialInsuranceApi
- `backend/src/routes/social.routes.ts` 社保配置查询
**优化方案**
1. 花名册社保费用计算改为读取社保模块中各险种的独立基数和比例
2. 养老保险用养老基数、医疗保险用医疗基数,分别计算后汇总
3. 确保两个模块的计算逻辑统一
---
### 问题20:合同附件PDF/Word不支持在线查看,且无法删除传错的附件
**模块**:花名册 - 劳动合同
**优先级**P0
**状态**:待修复
**现状描述**
劳务合同附件上传了 PDF 后不可以查看,显示没有插件;Word 也不支持在线查看,只有图片格式可以查看。且附件上传之后传错了无法删除,没有删除按钮。
**代码核查结果**
- `ContractInfo.tsx:391-455` 附件预览弹窗实现:
- **图片**`<img>` 在线预览 ✅(`:432`
- **PDF**`<embed>` 在线预览 ✅(`:434`)——已支持,非完全缺失
- **Word/其他**:显示"此文件格式不支持在线预览",提供下载 ❌(`:436-449`
- 附件上传支持格式:`.pdf, .jpg, .jpeg, .png, .heic, .gif, .bmp, .webp, .doc, .docx, .xls, .xlsx, .tiff, .tif``:36,106`
- **新建合同时**的附件可删除(`:258`)✅
- **已保存合同的附件无删除按钮**——只有下载按钮(`:335-358`)和补充上传按钮(`:363`)❌
- 附件以 base64 data URL 存储在 `attachmentUrl` 字段中,预览时转为 blob URL
**涉及文件**
- `frontend/src/pages/roster/ContractInfo.tsx:16-70` 附件上传逻辑
- `frontend/src/pages/roster/ContractInfo.tsx:258` 新建时删除附件按钮
- `frontend/src/pages/roster/ContractInfo.tsx:310-370` 已保存合同附件展示(无删除)
- `frontend/src/pages/roster/ContractInfo.tsx:391-455` 附件预览弹窗
**优化方案**
1. Word 预览:使用 `mammoth.js` 转换为 HTML 在线预览,或提示下载查看
2. 已保存合同的附件增加删除按钮,删除时二次确认
3. 后端增加附件删除接口,更新 `attachmentUrl` 字段
---
### 问题21:用工办理与花名册添加员工功能重复
**模块**:花名册 / 用工办理
**优先级**P2
**状态**:待优化
**现状描述**
花名册可以添加员工,用工办理也可以录入员工,两个模块添加员工有什么区别不清楚。如果都可以添加没有必要,最好固定在一个模块。
**代码核查结果**
- `Roster.tsx``AddEmployeeModal``modals.tsx:486-767`)直接创建员工
- `WorkProcess.tsx:56-66``HIRE` 流程类型也创建员工,字段为 `name``department``idCardNumber` 等 text 输入
- `WorkProcess.tsx:67-70``ONBOARD` 流程使用 `employee-select` 选择已有员工
- 两个入口都调用 `createEmployee`,写入同一张表
**涉及文件**
- `frontend/src/pages/Roster.tsx:70-90` 花名册状态和模态框
- `frontend/src/pages/roster/modals.tsx:486-767` AddEmployeeModal
- `frontend/src/pages/WorkProcess.tsx:56-70` HIRE/ONBOARD 流程定义
- `backend/src/routes/employee.routes.ts:99-126` 创建员工路由
- `backend/src/services/contract.service.ts:193-272` createEmployee
**优化方案**
1. 统一员工添加入口为「用工办理 → 入职办理」,包含完整入职流程
2. 花名册保留「查看」和「编辑」功能,移除独立添加入口
3. 或在花名册添加员工时引导跳转到用工办理的入职流程
---
### 问题22:用工办理录入中途切换窗口丢失已填信息
**模块**:用工办理
**优先级**P1
**状态**:待修复
**现状描述**
在用工办理里录入员工,录到身份证号处,点开别的文件想粘贴一下,再回去,刚才录入的页面就退出了,需要重新打开重新录前面的信息。
**代码核查结果**
- `frontend/src/components/ui/Modal.tsx:36` 遮罩层 `onClick={onClose}`——**点击遮罩层会关闭弹窗**
-`closeOnOverlayClick={false}` 配置选项
- 表单数据未持久化到 `sessionStorage`
- `AddEmployeeModal``modals.tsx:641`)使用了 `useUnsavedChanges(isDirty)` 但仅提示,不阻止关闭
- `WorkProcess.tsx:204-205` 录入弹窗也使用 `div` + `onClick={onClose}` 模式
**涉及文件**
- `frontend/src/components/ui/Modal.tsx:33-56` Modal 组件(遮罩层 onClick={onClose}
- `frontend/src/pages/roster/modals.tsx:641-643` AddEmployeeModal useUnsavedChanges
- `frontend/src/pages/WorkProcess.tsx:204-205` 录入弹窗
**优化方案**
1. 弹窗设置为 `closeOnOverlayClick={false}`,禁止点击遮罩层关闭
2. 表单数据持久化到 `sessionStorage`,重新打开时恢复
3. 关闭前增加「确认关闭?未保存的数据将丢失」提示
---
### 问题23:用工办理未按身份证号查重
**模块**:用工办理
**优先级**P1
**状态**:待修复
**现状描述**
在花名册录入一个人,在用工办理里录入了一个人但没录入身份证号,不显示重复,不知道是否用身份证查重。
**代码核查结果**
- `createEmployee``contract.service.ts:193-272`**无查重逻辑**——直接创建
- 数据库依赖 `idCardHash` 唯一约束,重复时抛出 P2002 错误
- `errorHandler.ts:27-31` P2002 错误返回通用"数据已存在,请勿重复操作"消息
- 前端 `WorkProcess.tsx``HIRE` 流程类型使用 `text` 类型字段(`name``department` 等),**非 `employee-select`**
- `INCOME_CERT``LEAVING_CERT` 也使用 `text` 类型手动输入员工信息(`:107-114, :129-134`
- `import.routes.ts:330-333` 导入时有身份证号查重,返回字段级错误信息
**涉及文件**
- `backend/src/services/contract.service.ts:193-272` createEmployee(无查重)
- `backend/src/middleware/errorHandler.ts:27-31` P2002 错误处理
- `frontend/src/pages/WorkProcess.tsx:56-66` HIRE 流程字段定义
- `frontend/src/pages/WorkProcess.tsx:107-114,129-134` 证明开具字段(手动输入)
- `backend/src/routes/import.routes.ts:330-333` 导入查重(有字段级错误)
**优化方案**
1. 用工办理录入时根据姓名+手机号或身份证号查重
2. 身份证号为空时用姓名+手机号组合查重
3. 发现重复时提示「该员工已存在,是否查看/跳转」
---
## 九、证明开具模块
### 问题24:收入证明等应支持员工下拉选择,直接拉取数据
**模块**:用工办理 - 证明开具
**优先级**P1
**状态**:待优化
**现状描述**
开具收入证明或其他证明时,需要手动粘贴员工信息,应该有员工下拉选项直接拉取数据,避免开具非本公司员工的证明。
**代码核查结果**
- `INCOME_CERT``WorkProcess.tsx:107-114`)字段为手动输入:`employeeName`text)、`idCardNumber`text)、`position`text)、`monthlyIncome`text
- `LEAVING_CERT``:129-134`)同样为手动输入
- **未使用 `employee-select` 类型**,不关联花名册
- **但批量开具证明弹窗(`:537-590`)已有员工多选列表**——单条开具时却无下拉选择
- `WorkProcess.tsx:746-809``EmployeeSelect` 组件实现,支持搜索和选择员工
- `WorkProcess.tsx:298-306` 批量提交时从员工数据自动填充 `employeeName``idCardNumber``position`
**涉及文件**
- `frontend/src/pages/WorkProcess.tsx:107-114` INCOME_CERT 字段定义(手动输入)
- `frontend/src/pages/WorkProcess.tsx:129-134` LEAVING_CERT 字段定义(手动输入)
- `frontend/src/pages/WorkProcess.tsx:537-590` 批量开具证明弹窗(有员工选择)
- `frontend/src/pages/WorkProcess.tsx:746-809` EmployeeSelect 组件
- `frontend/src/pages/WorkProcess.tsx:298-306` 批量提交自动填充字段
**优化方案**
1. 证明开具表单增加员工下拉选择器,支持姓名/手机号搜索
2. 选择员工后自动填充身份证号、入职日期、职务、月收入等字段
3. 只允许选择本公司在职员工
---
## 十、培训记录模块
### 问题25:培训记录只能选择单个员工,不支持批量/按部门
**模块**:培训记录
**优先级**P1
**状态**:待优化
**现状描述**
添加培训记录只能选择一个员工,但实际培训可能是好几个员工一起,也可能是一个部门甚至整个公司。
**代码核查结果**
- `TrainingRecords.tsx:211-220` 员工选择为 `<Select>` 单选下拉框,`employees.map` 渲染选项
- `AttendanceOvertimeInfo.tsx:79-81` 中的培训记录新增也为单选
- **不支持多选或按部门批量选择**
- 表单字段:`employeeId``trainingDate``topic``content``trainer``duration``remark`
**涉及文件**
- `frontend/src/pages/roster/TrainingRecords.tsx:200-267` 培训记录表单(单选员工)
- `frontend/src/pages/roster/AttendanceOvertimeInfo.tsx:10,79-81` 考勤/培训合并组件
- `backend/src/routes/employee.routes.ts` 培训记录接口
**优化方案**
1. 员工选择改为多选模式,支持按部门筛选勾选
2. 增加「按部门添加」和「全公司添加」快捷选项
3. 批量创建培训记录,每人选一条,共享培训主题/日期/讲师等信息
---
## 十一、绩效考核模块
### 问题26:绩效考核模块过于片面,应支持导入公司自定义考核表
**模块**:绩效考核
**优先级**P2
**状态**:待优化
**现状描述**
绩效考核模块只有简单的得分/等级/评语,每个公司考核类别、评分等差别比较大,现有功能几乎没法用。应支持导入本公司绩效考核表,再进行个人绩效考核统计。
**代码核查结果**
- `PerformanceInfo.tsx:14` 表单仅包含:`period``periodType`(月度/季度/年度)、`score``grade`A/B/C/D)、`result`(优秀/合格/需改进/不胜任)、`summary``improvementPlan``reviewer``employeeAck`
- 得分自动计算等级和结果(`:29-39`
- **无自定义考核维度、权重、指标**
- 不支持导入 Excel 考核表
**涉及文件**
- `frontend/src/pages/roster/PerformanceInfo.tsx:1-118` 绩效考核完整组件
- `backend/src/routes/employee.routes.ts` 绩效记录接口
- `backend/prisma/schema.prisma` PerformanceRecord 模型
**优化方案**
1. 增加「绩效模板」管理,支持定义考核维度、权重、评分标准
2. 支持导入 Excel 考核表作为模板
3. 绩效考核时按模板填写各维度得分,系统按权重计算总分
4. 保留现有简单模式作为默认,自定义模板作为高级功能
---
## 十二、考勤导入流程
### 问题27:考勤模板 Sheet 过多,导入后无法确认数据
**模块**:考勤管理
**优先级**P0
**状态**:待优化
**现状描述**
考勤管理下载模板时模板包含太多无关 Sheet,需要都删除后再导入。而且导入后提示导入成功,但找不到从哪里确认数据。
**代码核查结果**
- 与问题7相关,`import.routes.ts:494-692` 模板包含多个无关 Sheet
- `Attendance.tsx:525-610` 导入弹窗显示导入结果(成功数、跳过数、错误),**但无跳转到考勤确认页面的链接**
- 导入成功后仅 toast 提示,无自动跳转
**涉及文件**
- `backend/src/routes/import.routes.ts:494-692` 模板下载
- `frontend/src/pages/Attendance.tsx:525-610` 前端导入弹窗
- `frontend/src/pages/attendance/AttendanceConfirm.tsx` 考勤确认页面
**优化方案**
1. 模板精简为单个考勤 Sheet(与问题7统一处理)
2. 导入成功后 toast 提示中增加「点击查看」跳转链接
3. 导入成功后自动跳转到考勤确认页面
---
## 十三、加班费计算
### 问题28:加班费计算需重复导入考勤数据
**模块**:薪税管理 / 考勤管理
**优先级**P1
**状态**:待优化
**现状描述**
加班费计算跟考勤不关联,到加班费计算时还需要再导入一遍考勤。
**问题分析**
- 与问题9相同,加班费计算模块独立于考勤管理
- 考勤管理中已确认的加班数据未传递到薪税计算
**涉及文件**
- `frontend/src/pages/money/` 加班费相关
- `backend/src/routes/payroll2.routes.ts`
- `backend/src/routes/import.routes.ts` 考勤导入(含加班记录 Sheet
- `backend/src/routes/attendance.routes.ts` 考勤数据查询
**优化方案**
1. 与问题9统一处理:加班费从考勤管理读取已确认的加班记录
2. 薪税批次创建时自动拉取当月加班数据
---
## 优先级汇总
| 优先级 | 编号 | 问题 |
|--------|------|------|
| P0 | 1 | 福利方案无法添加人员 | 功能已实现,pageSize 200 条限制待优化 |
| P0 | 3 | 离职证明下载乱码 | 待修复 |
| P0 | 7 | 考勤导入模板无关Sheet+合并 | 确认:模板含员工信息/劳动合同等无关Sheet |
| P0 | 8 | 补卡无法修改状态+时间显示异常 | 后端支持,需确认前端弹窗状态限制 |
| P0 | 20 | 合同附件PDF/Word不支持查看+无法删除 | PDF已支持预览,Word不支持,已保存附件无法删除 |
| P0 | 27 | 考勤模板Sheet过多+导入后无法确认 | 确认:无导入后跳转引导 |
| P1 | 2 | 每页条数选择无反应 | 需验证usePageSize事件触发 |
| P1 | 4 | 离职导出缺少筛选条件 | 确认:导出未传筛选参数 |
| P1 | 5 | 已提交离职无法撤回+已撤回无法删除 | 后端有revoke接口,前端UI未暴露 |
| P1 | 9 | 加班费与考勤不关联 | 确认:独立模块 |
| P1 | 10 | 加班汇总不显示手动添加的加班 | 确认:数据源未关联 |
| P1 | 11 | 证据链验证无法定位异常 | 确认:仅显示汇总数字 |
| P1 | 12 | 规章制度签收缺少催办和明细 | 确认:无催办按钮,未签收人员未列出 |
| P1 | 13 | 文本模板不支持导入文档 | 确认:仅textarea输入 |
| P1 | 14 | 社保基数选择参保地后未封上下限 | 确认:未查询城市上下限 |
| P1 | 16 | 校验失败未指明具体字段 | 确认:仅显示通用错误 |
| P1 | 17 | 员工详情薪税入口意义不明 | 需进一步确认 |
| P1 | 18 | 录入时缺少职务字段 | **确认:前后端均缺失position字段** |
| P1 | 19 | 花名册社保计算与社保模块不一致 | 确认:使用统一基数 |
| P1 | 22 | 用工办理录入中途切换窗口丢失数据 | 确认:遮罩层点击关闭,无持久化 |
| P1 | 23 | 用工办理未按身份证号查重 | 确认:无查重逻辑,仅依赖DB唯一约束 |
| P1 | 24 | 证明开具不支持员工下拉选择 | 确认:手动输入,批量开具有选择器 |
| P1 | 25 | 培训记录不支持批量选择员工 | 确认:单选下拉框 |
| P1 | 28 | 加班费需重复导入考勤 | 与问题9相同 |
| P2 | 6 | 用工办理与离职管理功能重复 | 确认 |
| P2 | 15 | 社保基数修改不能直接覆盖 | 确认:value回退问题 |
| P2 | 21 | 花名册与用工办理添加员工重复 | 确认 |
| P2 | 26 | 绩效考核模块过于片面 | 确认:固定字段,无自定义 |
+208
View File
@@ -0,0 +1,208 @@
# 2026年8月11日 优化需求清单
> 用户反馈共 16 项问题,按模块和优先级整理如下。
---
## 一、花名册 / 员工详情模块
### 问题1:绩效考核点击员工姓名跳转首页
**模块**:花名册 → 绩效考核
**优先级**P1
**现象**:在绩效考核列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
**可能原因**:员工详情页的路由跳转参数缺失或未正确绑定 onClick 事件。
**优化方案**:检查绩效考核列表中员工姓名的点击事件,确保正确跳转到员工详情页(携带 employeeId 参数)。
---
### 问题3:培训记录点击员工姓名跳转首页
**模块**:花名册 → 培训记录
**优先级**P1
**现象**:在培训记录列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
**可能原因**:与问题1相同,员工姓名点击事件未正确绑定跳转逻辑。
**优化方案**:检查培训记录列表中员工姓名的点击事件,确保正确跳转到员工详情页。
---
### 问题14:特殊员工无法点击详情查看
**模块**:花名册 → 特殊员工
**优先级**P1
**现象**:特殊员工列表中只有编辑和删除按钮,无法点击查看员工详情。
**优化方案**:为特殊员工列表行添加点击查看详情功能,或增加"查看"按钮。
---
## 二、离职管理模块
### 问题2:离职证明模板是否可自定义,支持员工端下载带印章版本
**模块**:离职管理
**优先级**P1
**现象**:当前离职证明为固定模板,无法自定义修改。用户希望:
1. 离职证明模板可自定义或修改
2. 员工可通过小程序(员工端)下载带有企业电子印章的离职证明
**优化方案**
- 后端:支持离职证明模板配置(复用文本模板模块,支持变量占位符)
- 前端:离职管理中可选择/编辑离职证明模板
- 员工端:已完成离职的员工可在线查看和下载离职证明(含电子印章)
---
## 三、薪税管理模块
### 问题4:工资填写后数据自动归零
**模块**:薪税管理
**优先级**P0
**现象**:在薪税管理中选择一个员工生成工资,进入工资填写页面后录入数据,数据自动返回到 0。
**可能原因**:工资条编辑表单的状态管理问题,输入值未正确保存到 state,或被计算逻辑覆盖。
**优化方案**:检查工资条编辑表单的 onChange 和 onBlur 逻辑,确保手动输入的值不被自动计算覆盖。
---
## 四、社保公积金模块
### 问题5:社保公积金模块添加员工参保信息列表
**模块**:社保公积金
**优先级**P1
**现象**:社保公积金模块缺少员工参保信息列表,无法查看各员工各险种的参保状态和参保基数。
**优化方案**
- 社保公积金模块新增"员工参保列表"标签页
- 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)、缴费基数(养老/医疗/失业/工伤/生育)、公积金基数
- 支持按参保状态、城市筛选
---
## 五、商业保险模块
### 问题6:商业保险添加参保方案后无法为员工参保
**模块**:商业保险
**优先级**P0
**现象**:商业保险模块添加了保险方案后,找不到为员工添加参保信息的入口。
**优化方案**
- 在保险方案详情或列表中增加"为员工参保"按钮
- 支持批量选择员工参保
- 参保后显示参保人员列表
---
## 六、员工福利模块
### 问题7:员工福利添加方案后不知从何处为员工添加福利
**模块**:员工福利
**优先级**P0
**现象**:员工福利模块添加福利方案后,找不到为员工添加该项福利的入口。
**优化方案**
- 在福利方案详情或列表中增加"为员工添加福利"按钮
- 支持批量选择员工
- 添加后显示享受人员列表
---
### 问题15:员工福利新增方案后员工汇总不显示,月度合计为0
**模块**:员工福利
**优先级**P1
**现象**:新增福利方案并添加员工后,员工汇总中没有显示该福利,月度合计仍为 0。
**可能原因**:福利汇总统计逻辑未包含新增方案的金额,或汇总查询条件未关联到新增方案。
**优化方案**:检查福利汇总统计的查询逻辑,确保所有有效福利方案均纳入月度合计计算。
---
## 七、规章制度管理模块
### 问题8:新建规章制度无法进行文本或文档导入
**模块**:规章制度
**优先级**P1
**现象**:新建规章制度时,只能手动输入正文内容,无法导入 Word 文档或富文本。
**优化方案**
- 规章制度编辑器增加"导入 Word 文档"功能(复用文本模板模块的 mammoth 导入方案)
- 支持 .docx 格式文档导入并自动转为 HTML
---
## 八、文本模板模块
### 问题9:下载的 Word 文档毫无格式
**模块**:文本模板
**优先级**P1
**现象**:文本模板下载的 Word 文档没有格式,段落、标题、间距等全部丢失,需要手动调整。
**可能原因**:当前下载方式可能是纯文本写入 .doc 文件,未使用 Word HTML 格式或样式定义。
**优化方案**
- 下载的 Word 文档使用完整的 Word HTML 格式(含 style 定义)
- 保留标题、段落间距、字体大小、对齐方式等格式
- 参考离职证明下载的 Word HTML 方案
---
### 问题10:无固定期限劳动合同模板下载后不含员工信息
**模块**:文本模板
**优先级**P1
**现象**:使用"无固定期限劳动合同"模板,已填写了变量占位符,但下载的 Word 文档中变量未被替换为实际员工信息。
**可能原因**:下载时未对模板内容进行变量替换处理,直接输出了原始模板。
**优化方案**
- 下载 Word 文档时自动替换模板中的变量占位符(如 {{name}}、{{idCardNumber}} 等)
- 如果是从员工详情页发起下载,自动填充该员工的信息
- 如果是预览下载,提示用户填写变量或选择关联员工
---
## 九、电子签署模块
### 问题11:发起签署时选择员工没有选项
**模块**:电子签署
**优先级**P0
**现象**:电子签署中发起签署时,选择员工的下拉框为空,没有任何选项。
**可能原因**:员工列表查询接口未调用或返回数据为空,或下拉框数据绑定逻辑有误。
**优化方案**:检查电子签署发起页面中员工列表的数据获取逻辑,确保正确加载在职员工列表。
---
## 十、绩效考核模块
### 问题12:新增绩效记录没有编辑考评人的选项
**模块**:绩效考核
**优先级**P2
**现象**:绩效考核新增记录时,表单中没有考评人(考核人)的输入字段。
**优化方案**:在绩效记录新增表单中增加"考评人"输入框(已有 reviewer 字段,需确认前端是否显示)。
---
## 十一、违纪记录模块
### 问题13:违纪记录缺少处罚执行细节
**模块**:违纪记录
**优先级**P2
**现象**:违纪记录只有删除功能,缺少处罚执行的具体管理。用户希望支持:
1. 处罚类型为扣款/降职时,区分一次性处罚还是持续处罚(如罚一个月后恢复)
2. 停薪留职时,可设定留职时间段或灵活安排结束时间
**优化方案**
- 违纪记录新增"处罚执行"字段:处罚方式(一次性/持续性)、处罚开始日期、处罚结束日期、处罚金额/降职后岗位
- 停薪留职支持设定预计结束日期,到期后提醒确认是否恢复
- 违纪记录列表增加处罚状态列(执行中/已结束/已恢复)
---
## 十二、合规模块
### 问题16:证据链验证完整性全部失败
**模块**:合规 → 证据链
**优先级**P0
**现象**:合规模块中证据链验证完整性,所有验证项全部失败。
**可能原因**
1. 证据链哈希计算逻辑与存储时的哈希不一致(如排序规则、拼接顺序变化)
2. 历史数据在升级过程中哈希规则变更,导致旧数据全部校验失败
3. orgId 或其他参与哈希计算的参数发生变化
**优化方案**
- 检查 verifyAllEvidence 中的哈希计算逻辑,对比存储时的哈希生成逻辑
- 如果是哈希规则变更导致,提供"重新生成哈希"功能(仅管理员可用)
- 排查是否有数据迁移或 orgId 变更导致的不一致
---
## 汇总
| 优先级 | 数量 | 问题编号 |
|--------|------|----------|
| P0 | 5 | 4, 6, 7, 11, 16 |
| P1 | 7 | 1, 2, 3, 5, 8, 9, 10, 15 |
| P2 | 2 | 12, 13 |
| 待确认 | 1 | 14 |
| **合计** | **16** | |
+131
View File
@@ -16,6 +16,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.1",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1590,6 +1591,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
@@ -1639,6 +1649,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
@@ -1714,6 +1733,26 @@
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.1",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
@@ -1740,6 +1779,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
@@ -2263,6 +2308,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz",
@@ -2330,6 +2381,15 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3257,6 +3317,17 @@
"loose-envify": "cli.js"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3276,6 +3347,30 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/mammoth": {
"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",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -4256,6 +4351,12 @@
"node": ">= 6"
}
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
@@ -4309,6 +4410,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
@@ -5090,6 +5200,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
@@ -5378,6 +5494,12 @@
"node": ">=14.17"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
@@ -5719,6 +5841,15 @@
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -17,6 +17,7 @@
"file-saver": "^2.0.5",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"mammoth": "^1.12.1",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+22
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from './store/authStore'
import TopNav from './components/layout/TopNav'
import SidebarNav from './components/layout/SidebarNav'
import MobileTabBar from './components/layout/MobileTabBar'
import OnboardingGuide from './components/OnboardingGuide'
import PortalLayout from './components/layout/PortalLayout'
import PageContainer from './components/layout/PageContainer'
import { CommandPalette } from './components/ui/CommandPalette'
@@ -18,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'))
@@ -44,13 +47,21 @@ const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
const TrainingRecords = lazy(() => import('./pages/roster/TrainingRecords'))
const PerformanceRecords = lazy(() => import('./pages/roster/PerformanceRecords'))
const DisciplinaryRecords = lazy(() => import('./pages/roster/DisciplinaryRecords'))
// Sprint 4-5 新增页面
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
const MyEsign = lazy(() => import('./pages/portal/MyEsign'))
const MyRecords = lazy(() => import('./pages/portal/MyRecords'))
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits'))
const ESign = lazy(() => import('./pages/ESign'))
// 平台管理端
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
@@ -87,6 +98,7 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
</main>
<MobileTabBar />
</div>
<OnboardingGuide />
</div>
)
}
@@ -175,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>} />
@@ -194,8 +208,14 @@ export default function App() {
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
<Route path="/performance-records" element={<ProtectedRoute><AdminLayout><PerformanceRecords /></AdminLayout></ProtectedRoute>} />
<Route path="/disciplinary-records" element={<ProtectedRoute><AdminLayout><DisciplinaryRecords /></AdminLayout></ProtectedRoute>} />
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/commercial-insurance" element={<ProtectedRoute><AdminLayout><CommercialInsurance /></AdminLayout></ProtectedRoute>} />
<Route path="/benefits" element={<ProtectedRoute><AdminLayout><EmployeeBenefits /></AdminLayout></ProtectedRoute>} />
<Route path="/esign" element={<ProtectedRoute><AdminLayout><ESign /></AdminLayout></ProtectedRoute>} />
{/* 平台管理端 */}
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
@@ -216,6 +236,8 @@ export default function App() {
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
<Route path="/portal/records" element={<PortalLayoutWrapper><MyRecords /></PortalLayoutWrapper>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
+422 -16
View File
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles,
Home, Users, FileText, Calculator, Bot,
Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles, Bell,
Home, Users, FileText, Calculator, Bot, Calendar,
Settings, Lightbulb, AlertTriangle, CheckCircle, Phone, RotateCcw, ShieldAlert, TrendingDown } from 'lucide-react'
import Modal from './ui/Modal'
import { aiApi } from '../lib/api-services'
import { resetOnboarding } from './OnboardingGuide'
import clsx from 'clsx'
interface HelpCategory {
@@ -23,6 +25,304 @@ interface HelpArticle {
}
const categories: HelpCategory[] = [
{
id: 'whats-new',
title: '近期更新',
icon: Sparkles,
articles: [
{
id: 'update-20260811-overview',
question: '2026年8月11日优化:16项问题全面修复',
answer: '本次更新覆盖花名册、离职管理、薪税管理、社保公积金、商业保险、员工福利、规章制度、文本模板、电子签署、绩效考核、违纪记录、特殊员工、证据链等全部模块,共修复16项问题(P0紧急5项 + P1重要8项 + P2优化2项 + 待确认1项)。\n以下为各模块主要改进:',
},
{
id: 'update-perf-training-nav',
question: '修复:绩效考核/培训记录点击员工姓名可跳转员工详情',
answer: '绩效考核和培训记录列表中员工姓名现已支持点击跳转:\n• 点击员工姓名直接跳转到员工档案详情页\n• 跳转时携带 employeeId 参数,正确定位到对应员工\n• 员工姓名显示为链接样式,鼠标悬停有下划线提示',
tip: '路径:团队 → 绩效考核/培训记录 → 点击员工姓名',
},
{
id: 'update-resignation-cert',
question: '优化:离职证明模板支持自定义+员工端下载',
answer: '离职证明功能全面升级:\n• 离职证明模板支持自定义编辑(复用文本模板模块,支持变量占位符)\n• 管理端离职管理中可选择/编辑离职证明模板\n• 员工端已完成离职的员工可在线查看和下载离职证明(含电子印章)',
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明 / 员工端 → 我的证明',
},
{
id: 'update-payroll-input',
question: '修复(P0):薪税管理工资填写后数据不再归零',
answer: '修复工资条编辑表单输入数据后自动归零的问题:\n• 修复 onChange 和 onBlur 逻辑,手动输入的值不再被自动计算覆盖\n• 确保基本工资、津贴等手动输入字段正确保存到状态\n• 自动计算项目(如个税、社保扣款)在手动输入完成后重新计算',
tip: '路径:薪税管理 → 选择员工 → 工资填写页面',
},
{
id: 'update-social-enrollment',
question: '新增:社保公积金模块员工参保信息列表',
answer: '社保公积金模块新增「员工参保」标签页:\n• 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)\n• 显示缴费基数(养老/医疗/失业/工伤/生育)和公积金基数\n• 支持按参保状态、城市筛选',
tip: '路径:社保公积金 → 员工参保 Tab',
},
{
id: 'update-commercial-insurance',
question: '修复(P0):商业保险方案支持为员工参保',
answer: '商业保险模块新增为员工参保功能:\n• 保险方案详情中增加「为员工参保」按钮\n• 支持批量选择员工参保\n• 参保后显示参保人员列表',
tip: '路径:福利保障 → 商业保险 → 点击方案 → 为员工参保',
},
{
id: 'update-benefit-enroll-20260811',
question: '修复(P0):员工福利方案支持为员工添加福利',
answer: '员工福利模块新增为员工添加福利功能:\n• 福利方案详情中增加「为员工添加福利」按钮\n• 支持批量选择员工\n• 添加后显示享受人员列表和月度合计',
tip: '路径:福利保障 → 员工福利 → 点击方案 → 为员工添加福利',
},
{
id: 'update-policy-word-import',
question: '新增:规章制度支持导入 Word 文档',
answer: '规章制度编辑器新增导入 Word 文档功能:\n• 支持 .docx 格式 Word 文档导入\n• 使用 mammoth 库自动将 Word 内容转为 HTML\n• 导入后可在编辑器中继续修改\n• 保留原有格式(标题、段落、列表等)',
tip: '路径:规章制度 → 新建 → 导入 Word 文档按钮',
},
{
id: 'update-template-format',
question: '修复:文本模板下载 Word 文档保留完整格式',
answer: '文本模板下载的 Word 文档现已保留完整格式:\n• 使用完整的 Word HTML 格式(含 style 定义)\n• 保留标题、段落间距、字体大小、对齐方式等格式\n• 下载文件名使用模板名称,不再使用随机文件名',
tip: '路径:文本模板 → 下载 Word',
},
{
id: 'update-template-variables',
question: '修复:模板下载时变量占位符正确替换为实际数据',
answer: '模板下载时自动替换变量占位符:\n• 下载 Word 文档时自动替换模板中的 {{变量}} 占位符\n• 从员工详情页发起下载时自动填充该员工信息\n• 修复了 token 参数被误当作模板变量的问题',
tip: '路径:文本模板 → 填写变量 → 下载 / 员工详情 → 下载模板',
},
{
id: 'update-esign-employees',
question: '修复(P0):电子签署发起时员工下拉框正常显示',
answer: '修复电子签署发起签署时员工下拉框为空的问题:\n• 发起签署弹窗中员工下拉框正确加载在职员工列表\n• 显示员工姓名和部门信息\n• 修复员工列表数据获取逻辑',
tip: '路径:电子签署 → 发起签署 → 选择员工',
},
{
id: 'update-performance-reviewer',
question: '优化:新增绩效记录支持填写考评人',
answer: '绩效考核新增记录表单增加考评人字段:\n• 新增「考评人」必填输入框\n• 考评人信息保存到 reviewer 字段\n• 列表中显示考评人信息',
tip: '路径:团队 → 绩效考核 → 新增 → 考评人',
},
{
id: 'update-disciplinary-detail',
question: '优化:违纪记录新增处罚执行细节',
answer: '违纪记录新增处罚执行管理功能:\n• 违纪记录新增「执行细节」字段,支持填写处罚方式、处罚日期等\n• 违纪记录列表增加「执行细节」列\n• 表单中增加 textarea 用于详细记录处罚执行情况',
tip: '路径:团队 → 违纪记录 → 新增 → 执行细节',
},
{
id: 'update-special-status-detail',
question: '修复:特殊员工列表支持查看员工详情',
answer: '特殊员工列表新增查看详情功能:\n• 每条记录右上角新增「查看」按钮(眼睛图标)\n• 点击员工姓名也可跳转到员工档案详情页\n• 跳转时携带 employeeId 参数,正确定位到对应员工',
tip: '路径:团队 → 特殊员工 → 点击查看按钮或员工姓名',
},
{
id: 'update-benefit-summary',
question: '修复:员工福利汇总正确显示参保人员和月度合计',
answer: '员工福利汇总数据修复:\n• 新增福利方案并添加员工后,员工汇总正确显示\n• 月度合计金额正确计算所有有效福利方案\n• 汇总Tab显示参保人员姓名、部门、福利项和月度合计',
tip: '路径:福利保障 → 员工福利 → 员工汇总 Tab',
},
{
id: 'update-evidence-hash',
question: '修复(P0):证据链验证完整性全部通过',
answer: '证据链哈希验证逻辑修复:\n• 修复哈希计算中 key 排序问题,改为递归排序所有层级的 key\n• 验证时自动修复因 PostgreSQL JSONB key 重排或哈希算法升级导致的不一致\n• 历史数据验证全部通过,不再出现误报篡改',
tip: '路径:合规 → 证据链条 → 验证全部完整性',
},
{
id: 'update-2026-batch-overview',
question: '2026年8月批量优化:28项问题一次性修复',
answer: '本次更新覆盖员工福利、离职管理、考勤管理、证据链、规章制度、文本模板、花名册、用工办理、证明开具、培训记录、绩效考核等全部模块,共修复28项问题(P0紧急6项 + P1重要16项 + P2优化6项)。\n以下为各模块主要改进:',
},
{
id: 'update-benefit-enroll',
question: '修复:福利方案创建后无法添加享受人员',
answer: '修复福利方案批量参保功能:\n• 商业保险和员工福利模块均支持批量参保\n• 可选择多名员工一次性加入福利方案\n• 参保时自动记录生效日期和缴费金额',
tip: '路径:员工福利 → 福利方案 → 批量参保',
},
{
id: 'update-termination-cert',
question: '修复:离职证明下载内容为乱码',
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• 减少重复录入姓名和证件号码',
tip: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板',
},
{
id: 'update-attendance-makeup',
question: '修复:补卡无法修改未打卡状态',
answer: '修复每日出勤中补卡功能:\n• 补卡弹窗支持修改签到时间、签退时间、考勤状态\n• 可手动修正迟到、早退、缺勤等异常状态\n• 补卡操作记录备注信息,方便后续审计',
tip: '路径:考勤管理 → 每日出勤 → 补卡按钮',
},
{
id: 'update-attachment-view',
question: '新增:合同附件支持在线查看和删除',
answer: '员工档案附件管理新增在线查看和删除功能:\n• 点击眼睛图标可在线预览附件(PDF、图片等)\n• 点击下载图标可下载附件文件\n• 点击删除图标可删除传错的附件\n• 支持身份证、银行卡、学历证书、合同扫描件等多种类型',
tip: '路径:员工档案 → 附件管理',
},
{
id: 'update-page-size',
question: '修复:花名册每页条数选择无反应',
answer: '修复花名册列表切换每页显示条数后不生效的问题:\n• 分页组件改为使用本地 pageSize 状态而非服务端返回值\n• 切换条数后立即重置到第一页并重新加载数据',
tip: '路径:花名册列表底部 → 每页条数下拉框',
},
{
id: 'update-termination-export',
question: '优化:离职管理导出支持筛选条件',
answer: '离职管理导出数据新增筛选条件:\n• 支持按状态、部门、关键词筛选\n• 新增日期范围筛选(开始日期至结束日期)\n• 导出时携带当前筛选条件,只导出符合条件的数据',
tip: '路径:离职管理 → 筛选条件 → 导出按钮',
},
{
id: 'update-termination-cancel',
question: '新增:已提交离职数据可撤回,无用数据可删除',
answer: '离职管理新增撤回和删除功能:\n• 已提交的离职草稿可撤销(非已完成状态均可撤回)\n• 草稿状态和已撤销状态的记录可删除\n• 撤回和删除操作均记录审计日志',
tip: '路径:离职管理 → 草稿列表 → 撤销/删除按钮',
},
{
id: 'update-overtime-calc',
question: '优化:加班费自动计算,无需重复导入',
answer: '考勤导入时自动计算加班费,薪税模块直接读取:\n• 导入考勤数据时根据加班倍率配置自动计算加班费\n• 月度考勤报表自动汇总加班时长和加班费\n• 薪税管理发放工资时自动读取已计算的加班费\n• 不再需要在薪税模块重复导入加班数据',
tip: '路径:考勤管理 → 导入考勤(自动计算加班费)→ 薪税管理(自动读取)',
},
{
id: 'update-overtime-summary',
question: '修复:个人考勤记录添加后加班汇总不显示',
answer: '修复添加个人考勤记录后月度报表加班汇总不更新的问题:\n• 月度报表从 overtimeRecord 表读取加班汇总数据\n• 个人考勤记录中的加班时长自动累计到月度汇总',
tip: '路径:考勤管理 → 月度报表',
},
{
id: 'update-evidence-verify',
question: '优化:证据链验证显示异常项详情',
answer: '证据链「验证全部完整性」功能增强:\n• 验证结果新增异常项详情列表\n• 每条异常项显示:证据类型、关联ID、描述、创建时间\n• 方便快速定位被篡改的证据链记录',
tip: '路径:证据链管理 → 验证全部完整性',
},
{
id: 'update-policy-remind',
question: '新增:规章制度签收催办和未签收人员查看',
answer: '规章制度签收管理新增催办和未签收人员列表:\n• 签收统计中显示已签收和未签收人数\n• 可展开查看未签收人员明细列表\n• 一键催办功能:向所有未签收员工发送系统内通知提醒\n• 催办通知记录在通知管理中可查看',
tip: '路径:规章制度 → 点击制度 → 阅读统计 → 一键催办',
},
{
id: 'update-template-import',
question: '新增:模板支持导入 Word 文档',
answer: '文本模板编辑新增导入 Word 文档功能:\n• 支持 .docx 格式 Word 文档导入\n• 使用 mammoth 库自动将 Word 内容转为 HTML\n• 导入后可在编辑器中继续修改变量占位符\n• 保留原有格式(标题、段落、列表等)',
tip: '路径:文本模板 → 新建/编辑模板 → 导入 Word 文档按钮',
},
{
id: 'update-social-cap',
question: '修复:选择参保地后社保基数自动封上下限',
answer: '花名册添加员工时选择参保城市后自动封顶/保底社保基数:\n• 选择参保城市后自动调用社保计算接口\n• 社保基数超过上限自动封顶,低于下限自动保底\n• 公积金基数同样自动封顶/保底\n• 显示封顶/保底提示信息',
tip: '路径:花名册 → 添加员工 → 选择参保城市',
},
{
id: 'update-validation-detail',
question: '优化:录入校验失败显示具体字段和错误原因',
answer: '员工信息录入校验失败时显示字段级错误信息:\n• 后端 Zod 校验返回具体字段名和错误原因\n• 前端解析错误详情,逐条列出校验失败的字段\n• 添加员工弹窗和编辑表单均支持详细错误提示',
tip: '路径:花名册 → 添加/编辑员工 → 校验失败时显示',
},
{
id: 'update-position-field',
question: '修复:花名册录入时新增职务/岗位字段',
answer: '添加员工表单新增「职务/岗位」输入框:\n• 在姓名和部门旁边新增职务字段\n• 录入时可直接填写岗位信息\n• 与花名册列表中的职务列对应',
tip: '路径:花名册 → 添加员工 → 职务/岗位',
},
{
id: 'update-social-detail',
question: '新增:花名册员工详情显示社保费用分险种明细',
answer: '员工详情薪税信息中新增社保费用分险种明细展示:\n• 按养老、医疗、失业、工伤、生育分别显示企业和个人缴费金额及比例\n• 显示社保基数是否已封顶/保底\n• 医保基数与养老基数不同时单独提示\n• 与社保模块计算结果保持一致',
tip: '路径:员工档案 → 薪税信息 → 社保费用明细',
},
{
id: 'update-modal-noclose',
question: '优化:录入弹窗防止误关闭丢失已填信息',
answer: '添加员工等录入弹窗防止误操作关闭:\n• Modal 组件新增 closeOnOverlayClick 属性\n• 添加员工弹窗设置为点击遮罩层不关闭\n• 防止误点击弹窗外部导致已填信息丢失',
tip: '路径:花名册 → 添加员工弹窗',
},
{
id: 'update-idcard-dedup',
question: '新增:用工办理按证件号码查重',
answer: '创建员工时自动按证件号码查重:\n• 后端创建员工前先检查证件号码是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工',
},
{
id: 'update-income-cert-select',
question: '优化:收入证明支持员工下拉选择并自动填充',
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、证件号码、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工',
},
{
id: 'update-training-batch',
question: '新增:培训记录支持批量选择员工',
answer: '培训记录新增批量选择模式:\n• 支持切换单选/批量模式\n• 批量模式下可搜索姓名/部门并勾选多名员工\n• 一次为多名员工添加相同培训记录\n• 显示已选择员工数量',
tip: '路径:团队 → 培训记录 → 新增 → 切换为批量',
},
{
id: 'update-workprocess-dedup',
question: '优化:用工办理中离职/解聘流程统一归入离职管理',
answer: '用工办理模块与离职管理模块功能去重:\n• 用工办理中保留入职、转正、调岗、合同相关流程\n• 离职、解聘相关流程统一在「离职管理」模块处理\n• 清理用工办理中已废弃的流程类型定义',
tip: '路径:用工办理(入职类流程)→ 离职管理(离职类流程)',
},
{
id: 'update-social-input',
question: '优化:社保基数输入框支持直接覆盖',
answer: '社保和公积金基数输入框优化:\n• 自动填充的默认值改为 placeholder 显示,不再回填\n• 点击输入框时自动全选当前值,方便直接覆盖\n• 清空输入框后不再回退到月工资默认值\n• 添加员工和重新雇佣弹窗均已优化',
tip: '路径:花名册 → 添加员工 → 社保/公积金基数输入框',
},
{
id: 'update-payslip-entry',
question: '优化:花名册员工详情薪税入口改名为「查看薪资历史」',
answer: '员工详情中薪税模块入口按钮优化:\n• 按钮名称从「薪税模块」改为「查看薪资历史」,语义更明确\n• 跳转时携带员工ID和tab参数,直接定位到该员工的工资条\n• 与员工个人薪资关联,不再跳转到薪税批次列表',
tip: '路径:员工档案 → 薪酬社保 → 查看薪资历史',
},
{
id: 'update-roster-guide',
question: '优化:花名册添加员工后引导前往用工办理',
answer: '花名册添加员工成功后增加引导提示:\n• 添加成功后 toast 提示「员工已添加」\n• 提供「前往用工办理」快捷操作按钮\n• 引导用户使用用工办理完成完整入职流程(合同签署等)\n• 花名册保留快速添加入口,用工办理提供完整流程',
tip: '路径:花名册 → 添加员工 → 成功提示 → 前往用工办理',
},
{
id: 'update-performance-template',
question: '新增:绩效考核支持自定义模板和维度评分',
answer: '绩效考核模块新增绩效模板管理:\n• 支持定义自定义考核维度(如工作能力、态度、业绩等)\n• 每个维度可设置权重和满分分值\n• 考核时按模板填写各维度得分,系统按权重自动计算总分\n• 保留简单评分模式作为默认,自定义模板作为高级功能\n• 绩效模板支持增删改查,可设置默认模板',
tip: '路径:员工档案 → 绩效考核 → 新增 → 选择绩效模板',
},
],
},
{
id: 'home',
title: '首页',
icon: Home,
articles: [
{
id: 'system-intro',
question: '本系统能帮企业做什么?',
answer: '「企业用工专家」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成',
},
{
id: 'compliance',
question: '系统如何保障用工合规?',
answer: '系统从以下维度帮助企业实现合规管理:\n• 合同合规:自动提醒合同到期续签,检测未签合同风险(入职1个月内未签合同需支付双倍工资)\n• 薪酬合规:自动计算个税、社保扣款,确保发薪准确无误\n• 考勤合规:记录加班时长,预警超时加班风险,留存考勤证据\n• 解聘合规:自动计算经济补偿金,生成规范解聘协议,降低劳动争议风险\n• 社保合规:跟踪社保缴纳情况,提醒漏缴断缴\n• 风险预警:统一风险中心实时扫描所有数据,按高/中/低分级预警',
tip: '建议每周查看风险中心,每月核对薪税和考勤数据,确保合规无遗漏。',
},
{
id: 'workflow',
question: '日常人事工作流程是怎样的?',
answer: '系统覆盖企业日常人事管理的完整流程:',
steps: [
'入职:添加员工信息 → 签订合同 → 设置社保 → 安排排班',
'日常:考勤打卡 → 加班审批 → 休假管理 → 补卡修正',
'月度:导入考勤 → 确认考勤 → 计算工资 → 发放工资条 → 缴纳社保公积金 → 个税申报',
'合同:到期提醒 → 续签合同 → 合同确认',
'离职:发起解聘 → 计算补偿金 → 生成协议 → 完成离职',
],
},
{
id: 'value',
question: '使用系统能带来什么价值?',
answer: '• 提效:自动化算薪、考勤统计、合同管理,减少 80% 人工操作\n• 降险:法律风险自动检测预警,避免因疏忽导致的劳动纠纷和罚款\n• 省心:到期提醒、月度任务提醒,不再遗漏关键时间节点\n• 透明:员工可通过手机端查看工资条、合同、考勤记录,信息透明\n• 合规:所有操作留存记录,满足劳动法合规要求,应对审计无忧',
},
],
},
{
id: 'start',
title: '快速入门',
@@ -85,6 +385,24 @@ const categories: HelpCategory[] = [
answer: '请到「解聘管理」页面处理离职流程,系统会自动帮您计算经济补偿金、生成解聘协议书等法律文件。不要直接删除员工记录,保留记录有助于日后查证。',
warning: '直接删除员工会导致该员工的所有历史记录丢失,包括合同、工资单等。',
},
{
id: 'training-records',
question: '培训记录怎么管理?',
answer: '在左侧菜单「团队」分组下点击「培训记录」进入管理页面:\n• 点击「新增」按钮选择员工,填写培训主题、日期、讲师、时长等信息\n• 保存后记录状态为「待签收」,员工可在员工端「我的记录」中签收或拒绝\n• 列表显示签收状态(待签收/已签收/拒绝签收),支持按员工姓名搜索',
tip: '开启「电子签署设置 → 培训记录电子签」后,员工签收时需走电子签署流程,签收记录自动进入证据链。',
},
{
id: 'performance-records',
question: '绩效考核怎么录入和管理?',
answer: '在左侧菜单「团队」分组下点击「绩效考核」进入管理页面:\n• 点击「新增」选择员工,填写考核周期、得分、等级、结果、评语等\n• 保存后员工可在员工端查看并签字确认\n• 列表显示签字状态(待签字/已签字),支持按员工姓名搜索',
tip: '开启「电子签署设置 → 绩效考核电子签」后,员工签字时需走电子签署流程。',
},
{
id: 'disciplinary-records',
question: '违纪记录怎么管理?',
answer: '在左侧菜单「团队」分组下点击「违纪记录」进入管理页面:\n• 点击「新增」选择员工,填写违纪日期、类型、描述、严重程度、处理方式等\n• 可填写见证人信息,保存后员工可在员工端查看并签字确认\n• 列表显示签字状态(待签字/已签字),支持按员工姓名搜索',
warning: '违纪记录是劳动仲裁重要证据,建议如实记录并确保员工签字确认。开启电子签后签字记录自动进入证据链。',
},
],
},
{
@@ -146,21 +464,94 @@ const categories: HelpCategory[] = [
},
],
},
{
id: 'attendance',
title: '考勤管理',
icon: Calendar,
articles: [
{
id: 'attendance-overview',
question: '考勤管理有哪些功能?',
answer: '考勤管理包含 6 个子功能:\n• 考勤确认:导入考勤数据后批量确认并发布给员工\n• 班次管理:设置早班、晚班、弹性班等班次规则\n• 排班:按日期为员工分配班次,支持批量排班\n• 每日出勤:查看当日打卡情况,支持补卡修正\n• 月度报表:汇总月度出勤、迟到、加班数据\n• 休假记录:管理员工请假信息',
},
{
id: 'shift-setup',
question: '怎么设置班次?',
answer: '在考勤管理「班次管理」标签页中,点击「新增班次」按钮,设置班次名称、上下班时间、弹性时长和休息时长。每个班次可以设置不同颜色方便区分。',
tip: '常见班次:早班 08:00-17:00、晚班 14:00-23:00、弹性班 09:00-18:00(弹性30分钟)。',
},
{
id: 'schedule',
question: '怎么给员工排班?',
answer: '在考勤管理「排班」标签页中:',
steps: [
'选择日期',
'在员工列表中,未排班的员工行内有班次下拉框',
'选择班次后点击「排班」按钮即可',
'也可以点击「批量排班」按钮,勾选多个员工一次性分配班次',
],
tip: '支持按姓名或部门搜索,按部门筛选快速定位员工。',
},
{
id: 'attendance-import',
question: '怎么导入考勤数据?',
answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。',
tip: '证件号码优先匹配,未填时用姓名匹配。',
},
{
id: 'attendance-correct',
question: '员工漏打卡了怎么办?',
answer: '在「每日出勤」标签页中,找到对应员工,点击「补卡」按钮,手动填写签到/签退时间和状态即可修正记录。',
},
],
},
{
id: 'risk',
title: '风险检测',
icon: AlertTriangle,
title: '风险中心',
icon: ShieldAlert,
articles: [
{
id: 'what-is-risk',
question: '风险检测是什么意思',
answer: '统会自动扫描您的员工合同数据,发现可能存在的法律风险。比如:合同到期未续签、试用期超长、未签合同等。风险分为高、中、低三个等级,建议优先处理高风险项。',
question: '风险中心是什么?',
answer: '统一风险中心会自动扫描您的员工合同、薪酬、社保等数据,汇总所有潜在风险。包括:合同到期未续签、未签合同、试用期超长、薪酬异常、社保漏缴、退休提醒等。风险分为高、中、低三个等级,建议优先处理高风险项。',
tip: '访问路径:左侧菜单「风险中心」或直接访问 /risk-center。',
},
{
id: 'how-to-fix',
question: '发现风险后怎么处理?',
answer: '在首页「总览」页面可以看到风险概览。点击风险项可以跳转到对应员工详情,然后根据系统建议进行处理。处理完成后风险会自动消除。',
tip: '建议每周查看一次风险提醒,及时处理避免法律纠纷。',
answer: '在风险中心页面,每个风险项右侧有快捷操作按钮(如「续签」「转正」「处理」),点击即可跳转到对应页面处理。处理完成后风险会自动消除。',
tip: '建议每周查看一次风险中心,及时处理避免法律纠纷。',
},
{
id: 'risk-types',
question: '有哪些类型的风险?',
answer: '系统目前检测以下风险类型:\n• 合同风险:到期未续签、未签合同\n• 薪酬风险:薪资异常波动\n• 解聘风险:可能存在劳动争议\n• 月度任务:发薪、社保、公积金、个税等截止日提醒\n• 入职手续:入职材料不完整\n• 退休提醒:员工即将达到退休年龄',
},
],
},
{
id: 'termination',
title: '解聘管理',
icon: TrendingDown,
articles: [
{
id: 'termination-process',
question: '员工离职怎么处理?',
answer: '在「解聘管理」页面处理离职流程:',
steps: [
'点击「发起解聘」选择员工',
'填写解聘原因、离职日期等信息',
'系统自动计算经济补偿金',
'生成解聘协议书等法律文件',
'确认后完成解聘流程',
],
warning: '不要直接删除员工记录,保留记录有助于日后查证和合规。',
},
{
id: 'compensation',
question: '经济补偿金怎么算?',
answer: '系统根据员工工龄和月均工资自动计算经济补偿金:\n• 每满一年支付一个月工资\n• 六个月以上不满一年按一年算\n• 不满六个月支付半个月工资\n• 月工资按离职前12个月平均工资计算',
tip: '工资高于当地社平工资3倍的,按3倍封顶,最长补偿12年。',
},
],
},
@@ -195,7 +586,12 @@ const categories: HelpCategory[] = [
{
id: 'notification',
question: '怎么设置提醒?',
answer: '在「通知管理」页面可以设置各类提醒:\n• 合同到期提前提醒天数\n• 未签合同提醒\n• 试用期到期提醒等\n点击通知铃铛图标可以查看所有未读提醒。',
answer: '在「设置」页面的「通知设置」标签中可以配置:\n• 合同到期提前提醒天数\n• 未签合同提醒\n• 加班超时提醒\n• 工资条发布通知\n• 月度事务提醒(发薪日、社保日、公积金日、个税日)\n• 企业微信 Webhook 推送\n• 邮件通知\n点击顶部通知铃铛图标可以查看所有未读提醒。',
},
{
id: 'salary-dashboard',
question: '薪酬分析看板有什么用?',
answer: '薪酬分析看板在「薪税管理」页面中,提供:\n• 薪酬概览(员工总数、月均薪酬、中位数、年度总薪酬)\n• 部门薪酬对比(含人均薪酬排名)\n• 月度薪酬趋势(同比环比变化)\n帮助您了解薪酬分布情况,辅助预算决策。',
},
{
id: 'change-password',
@@ -217,12 +613,12 @@ const categories: HelpCategory[] = [
{
id: 'data-safe',
question: '我的数据安全吗?',
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如身份证号)都经过加密存储。',
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如证件号码)都经过加密存储。',
},
{
id: 'data-export',
question: '可以导出数据吗?',
answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次可以导出为 Excel 方便财务对账。',
answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次可以导出为 Excel 方便财务对账。考勤管理支持导出每日出勤和月度报表为 CSV 文件。',
},
{
id: 'multi-user',
@@ -247,6 +643,7 @@ interface RAGResult {
}
export default function HelpModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const navigate = useNavigate()
const [activeCategory, setActiveCategory] = useState(categories[0].id)
const [expandedArticle, setExpandedArticle] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
@@ -502,10 +899,19 @@ export default function HelpModal({ open, onClose }: { open: boolean; onClose: (
{/* 底部联系方式 */}
<div className="border-t border-gray-200 px-4 py-2.5 flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<HelpCircle className="w-3.5 h-3.5" />
AI
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5">
<HelpCircle className="w-3.5 h-3.5" />
AI
</span>
<button
onClick={() => { resetOnboarding(); onClose(); navigate('/'); setTimeout(() => window.location.reload(), 100) }}
className="flex items-center gap-1 text-primary hover:underline"
>
<RotateCcw className="w-3 h-3" />
</button>
</div>
<span>support@hr8ai.com</span>
</div>
</Modal>
+106 -42
View File
@@ -1,80 +1,144 @@
import { useState, useEffect } from 'react'
import { X, ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { X, ArrowRight, Home, Users, Calculator, CalendarCheck, ShieldAlert, Bot } from 'lucide-react'
const STORAGE_KEY = 'hr-onboarding-completed'
const STORAGE_KEY = 'hr-onboarding-dismissed'
const steps = [
const modules = [
{
icon: '🏠',
title: '这里看风险',
description: '首页展示企业用工风险总览,红色代表高风险项,点击「去处理」直接跳转操作。',
icon: Home,
color: 'text-blue-600',
bg: 'bg-blue-50',
title: '工作台',
desc: '风险总览、待办事项、日历事件',
path: '/',
},
{
icon: '',
title: '这里管花名册',
description: '花名册页面管理员工档案、劳动合同、附件,以及违纪、考勤、培训、绩效记录,可生成仲裁证据链。',
icon: Users,
color: 'text-indigo-600',
bg: 'bg-indigo-50',
title: '团队管理',
desc: '花名册、用工办理、离职管理、特殊状态',
path: '/roster',
},
{
icon: '💰',
title: '这里算薪税',
description: '薪税页面提供加班费、双倍工资、社保公积金计算器和工资条管理,输入参数实时计算。',
icon: Calculator,
color: 'text-amber-600',
bg: 'bg-amber-50',
title: '薪酬管理',
desc: '发薪批次、工资条、社保公积金、薪酬分析',
path: '/money',
},
{
icon: CalendarCheck,
color: 'text-green-600',
bg: 'bg-green-50',
title: '考勤时间',
desc: '考勤打卡、排班管理、休假审批',
path: '/attendance',
},
{
icon: ShieldAlert,
color: 'text-red-600',
bg: 'bg-red-50',
title: '合规风控',
desc: '风险中心、证据链、规章制度、用工体检',
path: '/risk-center',
},
{
icon: Bot,
color: 'text-purple-600',
bg: 'bg-purple-50',
title: 'AI 助手',
desc: '智能咨询、合同审查、判赔预测、人力分析',
path: '/ai-assistant',
},
]
export function isOnboardingDismissed() {
return localStorage.getItem(STORAGE_KEY) === '1'
}
export function dismissOnboarding() {
localStorage.setItem(STORAGE_KEY, '1')
}
export function resetOnboarding() {
localStorage.removeItem(STORAGE_KEY)
}
export default function OnboardingGuide() {
const [visible, setVisible] = useState(false)
const [step, setStep] = useState(0)
const [dontShow, setDontShow] = useState(false)
const navigate = useNavigate()
useEffect(() => {
const completed = localStorage.getItem(STORAGE_KEY)
if (!completed) {
if (!isOnboardingDismissed()) {
setVisible(true)
}
}, [])
const close = () => {
localStorage.setItem(STORAGE_KEY, '1')
if (dontShow) dismissOnboarding()
setVisible(false)
}
const goTo = (path: string) => {
if (dontShow) dismissOnboarding()
setVisible(false)
navigate(path)
}
if (!visible) return null
const current = steps[step]
const isLast = step === steps.length - 1
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="flex justify-end p-2">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<h2 className="text-base font-semibold">使</h2>
<button onClick={close} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 pb-6">
<div className="text-5xl text-center mb-4">{current.icon}</div>
<h2 className="text-lg font-semibold text-center mb-2">{current.title}</h2>
<p className="text-sm text-gray-600 text-center mb-6">{current.description}</p>
{/* 进度指示器 */}
<div className="flex justify-center gap-1.5 mb-6">
{steps.map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-1.5 bg-gray-300'}`}
/>
))}
<div className="px-5 py-4">
<p className="text-sm text-gray-500 mb-4"> 6 </p>
<div className="grid grid-cols-2 gap-3">
{modules.map((m) => {
const Icon = m.icon
return (
<button
key={m.title}
onClick={() => goTo(m.path)}
className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/[0.02] transition-all text-left"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${m.bg} ${m.color} shrink-0`}>
<Icon className="w-4.5 h-4.5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800">{m.title}</div>
<div className="text-xs text-gray-500 mt-0.5 leading-relaxed">{m.desc}</div>
</div>
</button>
)
})}
</div>
<div className="flex justify-between">
{step > 0 ? (
<button onClick={() => setStep(step - 1)} className="text-sm text-gray-500"></button>
) : <span />}
<div className="mt-4 flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
<input
type="checkbox"
checked={dontShow}
onChange={(e) => setDontShow(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/10"
/>
</label>
<button
onClick={() => isLast ? close() : setStep(step + 1)}
className="flex items-center gap-1 text-sm font-medium text-primary"
onClick={close}
className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
{isLast ? '开始使用' : '下一步'}
{!isLast && <ArrowRight className="w-4 h-4" />}
使
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
@@ -20,6 +20,9 @@ const ROUTE_MAP: Record<string, BreadcrumbItem> = {
'/leave-approval': { group: '员工管理', label: '休假审批' },
'/termination': { group: '员工管理', label: '解聘补偿' },
'/special-status': { group: '员工管理', label: '特殊状态' },
'/training-records': { group: '员工管理', label: '培训记录' },
'/performance-records': { group: '员工管理', label: '绩效考核' },
'/disciplinary-records': { group: '员工管理', label: '违纪记录' },
'/money': { group: '薪税社保', label: '薪税管理' },
'/social': { group: '薪税社保', label: '社保公积金' },
'/evidence': { group: '合规风控', label: '证据链' },
+29 -10
View File
@@ -5,6 +5,7 @@
import { Link, useLocation } from 'react-router-dom'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import clsx from 'clsx'
import {
LayoutDashboard, Users, CalendarCheck, UserX,
@@ -14,8 +15,10 @@ import {
Bell, ScrollText, Settings,
ChevronDown, ChevronRight,
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
} from 'lucide-react'
import Logo from '../ui/Logo'
import { settingsApi } from '../../lib/api-services'
interface NavItem {
path: string
@@ -33,16 +36,27 @@ const navGroups: NavGroup[] = [
title: '首页',
items: [
{ path: '/', label: '工作台', icon: LayoutDashboard },
{ path: '/calendar', label: '日历', icon: CalendarDays },
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
{ path: '/esign', label: '电子签署', icon: PenTool },
],
},
{
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: '/special-status', label: '特殊状态', icon: Heart },
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
{ path: '/disciplinary-records', label: '违纪记录', icon: AlertTriangle },
{ path: '/special-status', label: '特殊员工', icon: Heart },
],
},
{
title: '时间',
items: [
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
],
},
{
@@ -54,17 +68,17 @@ const navGroups: NavGroup[] = [
],
},
{
title: '时间',
title: '福利保障',
items: [
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
{ path: '/commercial-insurance', label: '商业保险', icon: Umbrella },
{ path: '/benefits', label: '员工福利', icon: Gift },
],
},
{
title: '合规',
items: [
{ path: '/risk-center', label: '风险中心', icon: ShieldAlert },
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/policies', label: '规章制度', icon: FileText },
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
@@ -79,7 +93,7 @@ const navGroups: NavGroup[] = [
{ path: '/notifications', label: '通知管理', icon: Bell },
{ path: '/audit', label: '操作日志', icon: ScrollText },
{ path: '/company-files', label: '公司文件', icon: Building2 },
{ path: '/settings', label: '设置', icon: Settings },
{ path: '/settings', label: '系统设置', icon: Settings },
],
},
]
@@ -89,13 +103,18 @@ const navGroups: NavGroup[] = [
*/
export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) {
const location = useLocation()
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/'
return location.pathname.startsWith(path)
}
const activeGroup = navGroups.find(g => g.items.some(item => isActive(item.path)))
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(activeGroup ? [activeGroup.title] : ['首页'])
new Set(navGroups.map(g => g.title))
)
const toggleGroup = (title: string) => {
@@ -131,7 +150,7 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
{/* Logo 区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
<Logo className="w-5 h-5 text-primary" />
<span className="font-bold text-sm text-gray-900"></span>
<span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || '企业用工专家'}</span>
</div>
{/* 导航菜单 */}
+1 -12
View File
@@ -3,7 +3,7 @@ import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle, Smartpho
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/authStore'
import { dashboardApi, settingsApi } from '../../lib/api-services'
import { dashboardApi } from '../../lib/api-services'
import Breadcrumb from './Breadcrumb'
import HelpModal from '../HelpModal'
import PortalQRModal from '../PortalQRModal'
@@ -22,11 +22,6 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
queryFn: () => dashboardApi.data(),
refetchInterval: 60000,
})
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const riskCount = dashboardData?.riskSummary?.pending || 0
return (
@@ -41,12 +36,6 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
>
<Menu className="w-5 h-5 text-gray-600" />
</button>
{orgData?.name && (
<span className="hidden sm:inline text-sm font-medium text-gray-700 shrink-0">
{orgData.name}
</span>
)}
{orgData?.name && <span className="hidden sm:inline text-gray-300 shrink-0">|</span>}
<Breadcrumb />
</div>
+3 -2
View File
@@ -9,9 +9,10 @@ interface ModalProps {
children: ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
closeOnOverlayClick?: boolean
}
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
export default function Modal({ open, onClose, title, children, className, size = 'md', closeOnOverlayClick = true }: ModalProps) {
const [show, setShow] = useState(false)
useEffect(() => {
@@ -33,7 +34,7 @@ export default function Modal({ open, onClose, title, children, className, size
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={clsx('fixed inset-0 bg-black/40 transition-opacity duration-200', show ? 'opacity-100' : 'opacity-0')}
onClick={onClose}
onClick={closeOnOverlayClick ? onClose : undefined}
/>
<div
className={clsx(
+2 -1
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { setPageSize } from '../../lib/pageSize'
interface PaginationProps {
page: number // 当前页(1-based
@@ -45,7 +46,7 @@ export default function Pagination({
<select
className="border rounded px-1.5 py-0.5 text-sm text-gray-600 focus:outline-none focus:border-primary"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
onChange={(e) => { setPageSize(Number(e.target.value)); onPageSizeChange?.(Number(e.target.value)) }}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>{n} /</option>
+1 -1
View File
@@ -274,7 +274,7 @@ export const surveyPages: SurveyPage[] = [
{
id: 24, name: 'Onboarding(入职信息填报)', file: 'frontend/src/pages/portal/Onboarding.tsx', menu: '员工端 > 入职填报',
features: [
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、身份证号、紧急联系人等' },
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、证件号码、紧急联系人等' },
{ id: '24.2', name: '入职材料上传', desc: '上传身份证、学历证明、银行卡等材料' },
{ id: '24.3', name: '文件类型选择', desc: '选择上传文件的类型(身份证正反面、学历、银行卡、其他)' },
{ id: '24.4', name: '已传文件管理', desc: '查看和删除已上传文件' },
+18
View File
@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react'
import { getPageSize } from '../lib/pageSize'
/**
* 响应式分页大小 hook
* 当用户在系统设置中修改分页大小时,所有使用此 hook 的页面会自动更新
*/
export function usePageSize() {
const [pageSize, setPageSizeState] = useState(getPageSize())
useEffect(() => {
const handler = () => setPageSizeState(getPageSize())
window.addEventListener('page-size-changed', handler)
return () => window.removeEventListener('page-size-changed', handler)
}, [])
return pageSize
}
+132
View File
@@ -66,6 +66,12 @@ export const employeeApi = {
/** 创建员工 */
create: (data: Record<string, unknown>) =>
post('/employees', data),
/** 身份证查重 */
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),
@@ -119,6 +125,30 @@ export const rosterApi = {
/** 即将到期合同 */
expiringContracts: () =>
get('/roster/contracts/expiring').then(unwrap<any[]>()),
/** 培训记录列表(全员) */
trainingList: (params: { page?: number; pageSize?: number; keyword?: string; ackStatus?: string }) =>
get('/roster/training/list', { params }).then(unwrap<any>()),
/** 培训记录催办 */
trainingRemind: (recordId: string) =>
post(`/roster/training/remind/${recordId}`).then(unwrap<any>()),
/** 绩效记录列表(全员) */
performanceList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/performance/list', { params }).then(unwrap<any>()),
/** 绩效模板列表 */
performanceTemplates: () =>
get('/roster/performance/templates').then(unwrap<any[]>()),
/** 创建绩效模板 */
createPerformanceTemplate: (data: any) =>
post('/roster/performance/templates', data).then(unwrap<any>()),
/** 更新绩效模板 */
updatePerformanceTemplate: (id: string, data: any) =>
put(`/roster/performance/templates/${id}`, data).then(unwrap<any>()),
/** 删除绩效模板 */
deletePerformanceTemplate: (id: string) =>
del(`/roster/performance/templates/${id}`).then(unwrap<any>()),
/** 违纪记录列表(全员) */
disciplinaryList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/disciplinary/list', { params }).then(unwrap<any>()),
/** 违纪记录 */
disciplinary: (employeeId: string) =>
get(`/roster/${employeeId}/disciplinary`).then(unwrap<any[]>()),
@@ -282,6 +312,9 @@ export const attendanceApi = {
/** 删除请假记录 */
removeLeave: (id: string) =>
del(`/attendance/leaves/${id}`),
/** 手动补卡/修正考勤 */
manualCorrect: (data: { employeeId: string; date: string; checkInTime?: string; checkOutTime?: string; status?: string; remark?: string }) =>
post('/attendance/manual-correct', data).then(unwrap<any>()),
}
// ========== 休假审批流 ==========
@@ -435,6 +468,9 @@ export const payrollApi = {
/** 批量导入加班工时 */
batchImportOvertime: (data: Record<string, unknown>[]) =>
post('/payroll/overtime/batch', data),
/** 从考勤记录同步加班工时 */
syncOvertimeFromAttendance: (month: string) =>
post('/payroll/overtime/sync-from-attendance', { month }).then(unwrap<any>()),
/** 导入加班费到批次 */
importOvertimeToBatch: (batchId: string) =>
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
@@ -552,6 +588,9 @@ export const socialInsuranceApi = {
/** 公积金活跃申报 */
housingActiveDeclaration: (month: string) =>
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
/** 员工参保信息列表 */
employeeEnrollment: (keyword?: string) =>
get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap<any[]>()),
}
// ========== 商业保险 ==========
@@ -569,6 +608,45 @@ export const commercialInsuranceApi = {
/** 删除方案 */
removePlan: (id: string) =>
del(`/commercial-insurance/plans/${id}`),
/** 批量参保 */
enroll: (planId: string, data: { employeeIds: string[]; premium?: number; effectiveFrom?: string }) =>
post(`/commercial-insurance/plans/${planId}/enroll`, data),
/** 退保 */
terminateEnrollment: (enrollmentId: string, effectiveTo?: string) =>
post(`/commercial-insurance/enrollments/${enrollmentId}/terminate`, { effectiveTo }),
/** 员工商险汇总 */
employeeSummary: () =>
get('/commercial-insurance/employee-summary').then(unwrap<any[]>()),
}
// ========== 员工福利 ==========
export const benefitApi = {
plans: () =>
get('/benefits/plans').then(unwrap<any[]>()),
savePlan: (data: Record<string, unknown>, editId?: string) =>
editId ? put(`/benefits/plans/${editId}`, data) : post('/benefits/plans', data),
removePlan: (id: string) =>
del(`/benefits/plans/${id}`),
enrollments: (planId: string) =>
get(`/benefits/plans/${planId}/enrollments`).then(unwrap<any[]>()),
enroll: (planId: string, data: { employeeIds: string[]; effectiveFrom?: string }) =>
post(`/benefits/plans/${planId}/enroll`, data),
terminateEnrollment: (enrollmentId: string, effectiveTo?: string) =>
post(`/benefits/enrollments/${enrollmentId}/terminate`, { effectiveTo }),
employeeSummary: () =>
get('/benefits/employee-summary').then(unwrap<any[]>()),
}
// ========== 电子签署(易签宝) ==========
export const esignApi = {
list: (params?: { status?: string; scene?: string }) =>
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string }) =>
post('/esign/create', data),
status: (id: string) =>
get(`/esign/${id}/status`).then(unwrap<any>()),
cancel: (id: string) =>
post(`/esign/${id}/cancel`),
}
// ========== 离职相关 ==========
@@ -604,6 +682,9 @@ export const terminationApi = {
/** 撤销 */
cancel: (draftId: string) =>
post(`/termination/draft/${draftId}/cancel`),
/** 删除草稿(仅 DRAFT 和 CANCELLED 状态) */
deleteDraft: (draftId: string) =>
del(`/termination/draft/${draftId}`),
/** 撤回离职记录 */
revoke: (recordId: string) =>
del(`/termination/${recordId}/revoke`),
@@ -642,6 +723,9 @@ export const policiesApi = {
/** 阅读签收统计 */
readStats: (id: string) =>
get(`/policies/${id}/read-stats`).then(unwrap<any>()),
/** 催办未签收员工 */
remind: (id: string, employeeIds?: string[]) =>
post(`/policies/${id}/remind`, { employeeIds }).then(unwrap<any>()),
}
// ========== 证据链相关 ==========
@@ -653,6 +737,12 @@ export const evidenceApi = {
/** 全量验证 */
verifyAll: () =>
get('/evidence/verify-all').then(unwrap<any>()),
/** 按员工获取证据链记录 */
byEmployee: (employeeId: string) =>
get(`/evidence/employee/${employeeId}`).then(unwrap<any[]>()),
/** 验证单条证据链 */
verify: (id: string) =>
get(`/evidence/verify/${id}`).then(unwrap<any>()),
}
// ========== 审计日志 ==========
@@ -747,6 +837,15 @@ export const settingsApi = {
/** 确认退休政策生效 */
confirmRetirementPolicy: (id: string) =>
post(`/settings/retirement-policy/${id}/confirm`),
/** 医疗期政策列表 */
medicalPeriodPolicies: () =>
get('/settings/medical-period/policies').then(unwrap<any[]>()),
/** 保存医疗期政策 */
saveMedicalPeriodPolicy: (data: Record<string, unknown>) =>
post('/settings/medical-period/policies', data),
/** 删除医疗期政策 */
deleteMedicalPeriodPolicy: (id: string) =>
del(`/settings/medical-period/policies/${id}`),
}
// ========== 模板相关 ==========
@@ -960,6 +1059,9 @@ export const portalApi = {
/** 撤回离职申请 */
resignationWithdraw: (id: string) =>
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
/** 下载离职证明 */
downloadCertificate: (id: string) =>
portalGet(`/resignation/${id}/certificate`, { responseType: 'blob' }) as any,
/** 我的休假申请列表 */
myLeaves: () =>
portalGet('/leaves').then(unwrap<any[]>()),
@@ -969,4 +1071,34 @@ export const portalApi = {
/** 撤回休假申请 */
cancelLeave: (id: string) =>
portalPost(`/leaves/${id}/cancel`).then(unwrap<any>()),
/** 我的电子签署列表 */
myEsignList: () =>
portalGet('/esign').then(unwrap<any[]>()),
/** 电子签署详情 */
esignDetail: (id: string) =>
portalGet(`/esign/${id}`).then(unwrap<any>()),
/** 签署操作 */
signEsign: (id: string) =>
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
/** 我的培训记录 */
myTraining: () =>
portalGet('/training').then(unwrap<any[]>()),
/** 培训签收 */
signTraining: (id: string) =>
portalPost(`/training/${id}/sign`).then(unwrap<any>()),
/** 培训拒绝签收 */
refuseTraining: (id: string) =>
portalPost(`/training/${id}/refuse`).then(unwrap<any>()),
/** 我的绩效记录 */
myPerformance: () =>
portalGet('/performance').then(unwrap<any[]>()),
/** 绩效签字 */
signPerformance: (id: string) =>
portalPost(`/performance/${id}/sign`).then(unwrap<any>()),
/** 我的违纪记录 */
myDisciplinary: () =>
portalGet('/disciplinary').then(unwrap<any[]>()),
/** 违纪签字 */
signDisciplinary: (id: string) =>
portalPost(`/disciplinary/${id}/sign`).then(unwrap<any>()),
}
+37
View File
@@ -0,0 +1,37 @@
import { toast } from 'sonner'
/**
* 复制文本到剪贴板,带 execCommand fallback
*/
export async function copyToClipboard(text: string, successMsg = '已复制') {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
toast.success(successMsg)
return
}
} catch {
// fall through to fallback
}
// fallback: execCommand('copy') + hidden textarea
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
if (ok) {
toast.success(successMsg)
} else {
toast.error('复制失败,请手动复制')
}
} catch {
toast.error('复制失败,请手动复制')
}
}
+19
View File
@@ -0,0 +1,19 @@
import { toast } from 'sonner'
/**
* 从 axios 错误中提取并显示错误信息,支持 Zod 校验失败时展示具体字段
*/
export function toastError(err: any, fallback = '操作失败') {
const error = err?.response?.data?.error
if (!error) {
toast.error(fallback)
return
}
// 如果有 details(Zod 校验失败),展示具体字段
if (error.details && Array.isArray(error.details) && error.details.length > 0) {
const fields = error.details.map((d: any) => `${d.path || '字段'}: ${d.message}`).join('')
toast.error(`${error.message}${fields}`)
return
}
toast.error(error.message || fallback)
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 全局分页大小管理
* 默认 10 条/页,用户可在系统设置中修改,存储在 localStorage
*/
const STORAGE_KEY = 'hr-page-size'
export const DEFAULT_PAGE_SIZE = 10
/** 获取当前分页大小 */
export function getPageSize(): number {
const val = localStorage.getItem(STORAGE_KEY)
const n = val ? parseInt(val, 10) : NaN
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PAGE_SIZE
}
/** 设置分页大小 */
export function setPageSize(size: number): void {
localStorage.setItem(STORAGE_KEY, String(size))
window.dispatchEvent(new CustomEvent('page-size-changed'))
}
+473 -59
View File
@@ -1,13 +1,16 @@
import { useState, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck, Edit } from 'lucide-react'
import { attendanceApi, employeeApi, rosterApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { usePageSize } from '../hooks/usePageSize'
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 Pagination from '../components/ui/Pagination'
import EmptyState from '../components/ui/EmptyState'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
@@ -80,7 +83,7 @@ export default function Attendance() {
})}
</div>
{activeTab === 'confirm' && <ConfirmTab />}
{activeTab === 'confirm' && <ConfirmTab onGoToTab={setActiveTab} />}
{activeTab === 'shifts' && <ShiftsTab />}
{activeTab === 'schedule' && <ScheduleTab />}
{activeTab === 'daily' && <DailyTab />}
@@ -91,7 +94,7 @@ export default function Attendance() {
}
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
@@ -102,7 +105,12 @@ function ConfirmTab() {
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [editItem, setEditItem] = useState<any>(null)
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0 })
const fileInputRef = useRef<HTMLInputElement>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month, filterDepartment, filterStatus],
@@ -180,10 +188,33 @@ function ConfirmTab() {
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '确认失败'),
})
const editMutation = useMutation({
mutationFn: async (data: any) => {
return await attendanceApi.manualCorrect(data)
},
onSuccess: () => {
toast.success('考勤记录已修改')
queryClient.invalidateQueries({ queryKey: ['attendance'] })
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
setEditItem(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '修改失败'),
})
const currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED')
const pendingCount = stats?.pending || 0
const pendingItems = (list || []).filter((i: any) => i.status === 'PENDING')
const allList = list || []
const filteredList = allList.filter((i: any) => {
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!i.employee?.name?.toLowerCase().includes(q) && !i.employee?.department?.toLowerCase().includes(q)) return false
}
return true
})
const pendingItems = filteredList.filter((i: any) => i.status === 'PENDING')
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((i: any) => selectedIds.has(i.id))
const total = filteredList.length
const pagedList = filteredList.slice((page - 1) * pageSize, page * pageSize)
const toggleSelect = (id: string) => {
const next = new Set(selectedIds)
@@ -300,6 +331,13 @@ function ConfirmTab() {
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
@@ -345,11 +383,12 @@ function ConfirmTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
) : total === 0 ? (
<EmptyState title="本月暂无考勤确认记录" description="请先批量导入考勤数据" />
) : (
<>
<div className="space-y-2">
{list.map((item: any) => {
{pagedList.map((item: any) => {
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
const StatusIcon = config.icon
const isSelected = selectedIds.has(item.id)
@@ -387,13 +426,32 @@ function ConfirmTab() {
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{item.status === 'PENDING' && (
<button
className="text-xs text-primary hover:underline"
onClick={() => singleConfirmMutation.mutate(item.id)}
disabled={singleConfirmMutation.isPending}
>
</button>
<>
<button
className="text-xs text-primary hover:underline"
onClick={() => singleConfirmMutation.mutate(item.id)}
disabled={singleConfirmMutation.isPending}
>
</button>
<button
className="text-xs text-gray-500 hover:text-primary"
onClick={() => {
setEditItem(item)
setEditForm({
workDays: item.workDays || 0,
lateCount: item.lateCount || 0,
earlyLeaveCount: item.earlyLeaveCount || 0,
absentDays: item.absentDays || 0,
leaveDays: item.leaveDays || 0,
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
overtimePay: item.overtimePay || 0,
})
}}
>
</button>
</>
)}
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
@@ -405,6 +463,63 @@ function ConfirmTab() {
)
})}
</div>
{editItem && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setEditItem(null)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()} className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {editItem.employee?.name}</h2>
<button onClick={() => setEditItem(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.workDays}
onChange={(e) => setEditForm({ ...editForm, workDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.lateCount}
onChange={(e) => setEditForm({ ...editForm, lateCount: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500">退</label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.earlyLeaveCount}
onChange={(e) => setEditForm({ ...editForm, earlyLeaveCount: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.absentDays}
onChange={(e) => setEditForm({ ...editForm, absentDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.leaveDays}
onChange={(e) => setEditForm({ ...editForm, leaveDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" step="0.5" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimeHours}
onChange={(e) => setEditForm({ ...editForm, overtimeHours: Number(e.target.value) })} />
</div>
<div className="col-span-2">
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimePay}
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}></Button>
<Button size="sm" onClick={() => editMutation.mutate({ employeeId: editItem.employeeId, month, ...editForm })} disabled={editMutation.isPending}>
{editMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Card>
</div>
)}
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
{/* 导入考勤弹窗 */}
@@ -423,14 +538,14 @@ function ConfirmTab() {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
const res = await fetch(`${baseURL}/import/monthly-template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '员工导入模板.xlsx'
a.download = '考勤月度导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
@@ -440,7 +555,7 @@ function ConfirmTab() {
</div>
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
Sheet
Sheet 0
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
@@ -455,6 +570,10 @@ function ConfirmTab() {
<div className="font-medium"></div>
{importResult.attendance > 0 && <div>{importResult.attendance} </div>}
{importResult.overtime > 0 && <div>{importResult.overtime} </div>}
{importResult.discipline > 0 && <div>{importResult.discipline} </div>}
{importResult.salaryChanges > 0 && <div>{importResult.salaryChanges} </div>}
{importResult.socialInsChanges > 0 && <div>{importResult.socialInsChanges} </div>}
{importResult.housingFundChanges > 0 && <div>{importResult.housingFundChanges} </div>}
{importResult.employees > 0 && <div>{importResult.employees} </div>}
{importResult.contracts > 0 && <div>{importResult.contracts} </div>}
{importResult.skipped > 0 && <div className="text-amber-600"> {importResult.skipped} </div>}
@@ -464,6 +583,12 @@ function ConfirmTab() {
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
<button
className="mt-1 text-primary hover:underline font-medium"
onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null); onGoToTab?.('confirm') }}
>
</button>
</div>
)}
@@ -629,6 +754,10 @@ function ScheduleTab() {
const [selectedShiftId, setSelectedShiftId] = useState('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [inlineShiftId, setInlineShiftId] = useState<Record<string, string>>({})
const { data: shifts } = useQuery<any>({
queryKey: ['shifts'],
@@ -678,9 +807,20 @@ function ScheduleTab() {
batchAssignMutation.mutate(items)
}
const employees = dailyData || []
const allEmployees = dailyData || []
const assignmentMap: Map<string, any> = new Map((assignments || []).map((a: any) => [a.employeeId, a]))
const filteredEmployees = allEmployees.filter((emp: any) => {
if (filterDept && emp.department !== filterDept) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!emp.name?.toLowerCase().includes(q) && !emp.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredEmployees.length
const employees = filteredEmployees.slice((page - 1) * pageSize, page * pageSize)
const toggleEmployee = (id: string) => {
const next = new Set(selectedEmployeeIds)
if (next.has(id)) next.delete(id)
@@ -688,18 +828,43 @@ function ScheduleTab() {
setSelectedEmployeeIds(next)
}
const handleInlineAssign = (employeeId: string) => {
const shiftId = inlineShiftId[employeeId]
if (!shiftId) return toast.error('请先选择班次')
batchAssignMutation.mutate([{ employeeId, shiftId, date }])
}
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={e => { setDate(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allEmployees.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button onClick={() => setShowAssign(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -707,9 +872,10 @@ function ScheduleTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : employees.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无员工" description="没有可排班的员工" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -717,7 +883,7 @@ function ScheduleTab() {
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center w-48"></th>
</tr>
</thead>
<tbody>
@@ -726,7 +892,7 @@ function ScheduleTab() {
return (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-gray-500">{emp.department || '未分配'}</td>
<td className="px-4 py-3">
{assignment ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs" style={{ background: (assignment.shift as any)?.color + '20', color: (assignment.shift as any)?.color }}>
@@ -737,10 +903,29 @@ function ScheduleTab() {
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-center">
{assignment && (
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}></button>
)}
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
{assignment ? (
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}></button>
) : (
<>
<select
value={inlineShiftId[emp.employeeId] || ''}
onChange={e => setInlineShiftId(prev => ({ ...prev, [emp.employeeId]: e.target.value }))}
className="h-7 rounded border border-gray-200 text-xs px-1 max-w-[100px]"
>
<option value=""></option>
{(shifts || []).map((s: any) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<button
className="text-xs text-primary hover:underline whitespace-nowrap"
onClick={() => handleInlineAssign(emp.employeeId)}
></button>
</>
)}
</div>
</td>
</tr>
)
@@ -748,6 +933,8 @@ function ScheduleTab() {
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
<Modal open={showAssign} onClose={() => setShowAssign(false)} title="批量排班">
@@ -771,11 +958,7 @@ function ScheduleTab() {
className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{employees.filter((emp: any) => {
if (!searchQuery.trim()) return true
const q = searchQuery.trim().toLowerCase()
return emp.name?.toLowerCase().includes(q) || emp.department?.toLowerCase().includes(q)
}).map((emp: any) => (
{filteredEmployees.map((emp: any) => (
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
<span className="text-sm">{emp.name}</span>
@@ -796,7 +979,14 @@ function ScheduleTab() {
// ========== 每日出勤 Tab ==========
function DailyTab() {
const queryClient = useQueryClient()
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [editEmp, setEditEmp] = useState<any>(null)
const [editForm, setEditForm] = useState({ checkInTime: '', checkOutTime: '', status: 'NORMAL', remark: '' })
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery<any>({
queryKey: ['daily-attendance', date],
@@ -805,6 +995,16 @@ function DailyTab() {
},
})
const correctMutation = useMutation({
mutationFn: (data: any) => attendanceApi.manualCorrect(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
toast.success('考勤记录已修正')
setEditEmp(null)
},
onError: () => toast.error('修正失败'),
})
const statusColors: Record<string, string> = {
NORMAL: 'bg-green-50 text-green-700',
LATE: 'bg-amber-50 text-amber-700',
@@ -815,25 +1015,75 @@ function DailyTab() {
UNREGISTERED: 'bg-gray-100 text-gray-500',
}
const allData = data || []
const filteredData = allData.filter((emp: any) => {
if (filterDept && emp.department !== filterDept) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!emp.name?.toLowerCase().includes(q) && !emp.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredData.length
const pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
//退/
</PageGuide>
<div className="flex justify-end">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allData.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button variant="secondary" size="sm" onClick={() => {
if (!data || data.length === 0) return
const headers = ['姓名', '部门', '班次', '签到', '签退', '状态', '工时']
const rows = data.map((emp: any) => [
emp.name, emp.department, emp.shift?.name || '', emp.checkInTime || '', emp.checkOutTime || '',
ATTENDANCE_STATUS[emp.status] || emp.status, emp.workHours > 0 ? `${emp.workHours}h` : '0',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤-${date}.csv`
a.click()
URL.revokeObjectURL(url)
}} disabled={!data || data.length === 0}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无员工" description="没有出勤数据" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -845,27 +1095,91 @@ function DailyTab() {
<th className="px-4 py-3 text-left">退</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((emp: any) => (
{pagedData.map((emp: any) => (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime ? (() => { const d = new Date(emp.checkInTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime ? (() => { const d = new Date(emp.checkOutTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[emp.status] || 'bg-gray-100 text-gray-500'}`}>
{ATTENDANCE_STATUS[emp.status] || emp.status}
</span>
</td>
<td className="px-4 py-3 text-right text-xs">{emp.workHours > 0 ? `${emp.workHours}h` : '—'}</td>
<td className="px-4 py-3 text-center">
<button
className="text-xs text-primary hover:underline"
onClick={() => {
setEditEmp(emp)
const fmtTime = (t: string) => { if (!t) return ''; const d = new Date(t); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}` }
setEditForm({
checkInTime: fmtTime(emp.checkInTime),
checkOutTime: fmtTime(emp.checkOutTime),
status: emp.status || 'NORMAL',
remark: '',
})
}}
>
<Edit className="w-3.5 h-3.5 inline" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
{/* 补卡弹窗 */}
{editEmp && (
<Modal open onClose={() => setEditEmp(null)}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium"> - {editEmp.name}</h3>
<button onClick={() => setEditEmp(null)} className="text-gray-500 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="text-xs text-gray-500">{date}</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="time" value={editForm.checkInTime} onChange={(e) => setEditForm({ ...editForm, checkInTime: e.target.value })} />
</div>
<div>
<Label>退</Label>
<Input type="time" value={editForm.checkOutTime} onChange={(e) => setEditForm({ ...editForm, checkOutTime: e.target.value })} />
</div>
<div className="col-span-2">
<Label></Label>
<Select value={editForm.status} onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}>
<option value="NORMAL"></option>
<option value="LATE"></option>
<option value="EARLY_LEAVE">退</option>
<option value="ABSENT"></option>
<option value="LEAVE"></option>
<option value="BUSINESS_TRIP"></option>
<option value="UNREGISTERED"></option>
</Select>
</div>
<div className="col-span-2">
<Label></Label>
<Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} placeholder="补卡原因/备注" />
</div>
</div>
<Button onClick={() => correctMutation.mutate({ employeeId: editEmp.employeeId, date, ...editForm })} disabled={correctMutation.isPending} className="w-full">
{correctMutation.isPending ? '提交中...' : '确认修正'}
</Button>
</div>
</Modal>
)}
</div>
)
@@ -874,6 +1188,10 @@ function DailyTab() {
// ========== 月度报表 Tab ==========
function MonthlyTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery<any>({
queryKey: ['monthly-report', month],
@@ -899,18 +1217,75 @@ function MonthlyTab() {
URL.revokeObjectURL(url)
}
const handleExportSingle = (r: any) => {
const headers = ['项目', '数值']
const rows = [
['姓名', r.name],
['部门', r.department],
['月份', month],
['出勤天数', r.workDays],
['迟到次数', r.lateCount],
['早退次数', r.earlyLeaveCount],
['缺勤天数', r.absentDays],
['请假天数', r.leaveDays],
['加班工时', r.overtimeHours?.toFixed(1) || '0'],
['加班费', `¥${r.overtimePay?.toFixed(2) || '0.00'}`],
['确认状态', r.confirmationStatus === 'CONFIRMED' ? '已确认' : r.confirmationStatus === 'PENDING' ? '待确认' : r.confirmationStatus === 'DISPUTED' ? '有异议' : '未创建'],
]
const csv = [headers, ...rows].map(row => row.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤明细-${r.name}-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success(`已导出 ${r.name}${month} 月考勤明细`)
}
const allData = data || []
const filteredData = allData.filter((r: any) => {
if (filterDept && r.department !== filterDept) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!r.name?.toLowerCase().includes(q) && !r.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredData.length
const pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allData.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button variant="secondary" onClick={handleExport} disabled={!data || data.length === 0}>
<BarChart3 className="w-4 h-4 mr-1" /> CSV
</Button>
@@ -918,9 +1293,10 @@ function MonthlyTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无报表数据" description="该月份没有出勤数据" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -935,10 +1311,11 @@ function MonthlyTab() {
<th className="px-4 py-3 text-center">(h)</th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((r: any) => (
{pagedData.map((r: any) => (
<tr key={r.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3 text-gray-500">{r.department}</td>
@@ -955,11 +1332,21 @@ function MonthlyTab() {
: r.confirmationStatus === 'DISPUTED' ? <span className="text-xs text-red-600"></span>
: <span className="text-xs text-gray-400"></span>}
</td>
<td className="px-4 py-3 text-center">
<button
className="text-xs text-primary hover:underline"
onClick={() => handleExportSingle(r)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
</div>
)
@@ -971,6 +1358,9 @@ function LeavesTab() {
const confirm = useConfirm()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
const [searchQuery, setSearchQuery] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: leaves, isLoading } = useQuery<any>({
queryKey: ['leave-records'],
@@ -1006,13 +1396,34 @@ function LeavesTab() {
}
const employees = rosterData || []
const allLeaves = leaves || []
const filteredLeaves = allLeaves.filter((lv: any) => {
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!lv.employee?.name?.toLowerCase().includes(q) && !lv.employee?.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredLeaves.length
const pagedLeaves = filteredLeaves.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex justify-end">
<div className="flex justify-end items-center gap-3">
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44"
/>
<Link to="/leave-approval" className="text-xs text-primary hover:underline flex items-center gap-1">
<Plane className="w-3.5 h-3.5" />
</Link>
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -1020,11 +1431,12 @@ function LeavesTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !leaves || leaves.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无休假记录" description="点击右上角添加休假记录" />
) : (
<>
<div className="space-y-2">
{leaves.map((lv: any) => (
{pagedLeaves.map((lv: any) => (
<Card key={lv.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
@@ -1050,6 +1462,8 @@ function LeavesTab() {
</Card>
))}
</div>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title="新增休假记录">
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery } from '@tanstack/react-query'
import { ScrollText } from 'lucide-react'
import { auditApi } from '../lib/api-services'
@@ -118,8 +119,8 @@ function formatDetail(detail: any): string {
*
*/
export default function AuditLog() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [action, setAction] = useState('')
const [entity, setEntity] = useState('')
const [dateFrom, setDateFrom] = useState('')
@@ -236,7 +237,7 @@ export default function AuditLog() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
+144 -39
View File
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, AlertCircle, Clock, Bell } from 'lucide-react'
import { dashboardApi, calendarApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -15,6 +15,7 @@ const EVENT_TYPE_COLORS: Record<string, string> = {
ANNIVERSARY: 'bg-green-100 text-green-700 border-green-200',
RISK_DEADLINE: 'bg-orange-100 text-orange-700 border-orange-200',
RETIREMENT: 'bg-purple-100 text-purple-700 border-purple-200',
PAYROLL_DAY: 'bg-emerald-100 text-emerald-700 border-emerald-200',
CUSTOM: 'bg-blue-100 text-blue-700 border-blue-200',
MEETING: 'bg-cyan-100 text-cyan-700 border-cyan-200',
TEAM_BUILDING: 'bg-pink-100 text-pink-700 border-pink-200',
@@ -29,6 +30,7 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
ANNIVERSARY: '入职周年',
RISK_DEADLINE: '风险截止',
RETIREMENT: '退休',
PAYROLL_DAY: '发薪日',
CUSTOM: '自定义',
MEETING: '会议',
TEAM_BUILDING: '团建',
@@ -119,6 +121,34 @@ export default function Calendar() {
return events
}, [calendarData, typeFilter])
const todayStr = fmtDate(new Date())
const eventStats = useMemo(() => {
const overdue: any[] = []
const urgent: any[] = []
const warning: any[] = []
const remind: any[] = []
for (const ev of allEvents) {
const diff = Math.floor((new Date(ev.date).getTime() - new Date(todayStr).getTime()) / 86400000)
if (diff < 0) overdue.push({ ...ev, overdueDays: -diff })
else if (diff <= 7) urgent.push(ev)
else if (diff <= 15) warning.push(ev)
else if (diff <= 35) remind.push(ev)
}
const totalOverdueDays = overdue.reduce((s, e) => s + e.overdueDays, 0)
return { overdue, urgent, warning, remind, totalOverdueDays }
}, [allEvents, todayStr])
const groupedEvents = useMemo(() => {
const groups = [
{ key: 'overdue', label: '已逾期', color: 'text-red-600', bg: 'bg-red-50', icon: AlertCircle, items: eventStats.overdue },
{ key: 'urgent', label: '7天内紧急', color: 'text-orange-600', bg: 'bg-orange-50', icon: AlertCircle, items: eventStats.urgent },
{ key: 'warning', label: '15天预警', color: 'text-amber-600', bg: 'bg-amber-50', icon: Clock, items: eventStats.warning },
{ key: 'remind', label: '35天提醒', color: 'text-blue-600', bg: 'bg-blue-50', icon: Bell, items: eventStats.remind },
]
return groups.filter(g => g.items.length > 0)
}, [eventStats])
const customEventMap = useMemo(() => {
const map: Record<string, any> = {}
for (const ev of (customEvents || [])) {
@@ -188,6 +218,38 @@ export default function Calendar() {
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-red-50 text-red-600"><AlertCircle className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-red-600">{eventStats.overdue.length}</div>
<div className="text-xs text-gray-500">{eventStats.totalOverdueDays}</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-orange-50 text-orange-600"><AlertCircle className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-orange-600">{eventStats.urgent.length}</div>
<div className="text-xs text-gray-500">7</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-50 text-amber-600"><Clock className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-amber-600">{eventStats.warning.length}</div>
<div className="text-xs text-gray-500">15</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 text-blue-600"><Bell className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-blue-600">{eventStats.remind.length}</div>
<div className="text-xs text-gray-500">35</div>
</div>
</Card>
</div>
{/* 类型筛选 */}
<div className="flex items-center gap-2 flex-wrap">
<button
@@ -235,16 +297,36 @@ export default function Calendar() {
{cell.day}
</div>
<div className="space-y-0.5">
{cell.events.slice(0, 3).map((ev: any, idx: number) => (
<div
key={idx}
className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
>
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
{ev.title}
</div>
))}
{cell.events.slice(0, 3).map((ev: any, idx: number) => {
const content = (
<>
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
{ev.title}
</>
)
if (ev.actionUrl && ev.actionUrl !== '/dashboard') {
return (
<Link
key={idx}
to={ev.actionUrl}
className={`block text-[10px] leading-tight px-1 py-0.5 rounded truncate hover:underline ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
onClick={(e) => e.stopPropagation()}
>
{content}
</Link>
)
}
return (
<div
key={idx}
className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
>
{content}
</div>
)
})}
{cell.events.length > 3 && (
<div className="text-[10px] text-gray-400 px-1">+{cell.events.length - 3} </div>
)}
@@ -266,37 +348,60 @@ export default function Calendar() {
<span className="text-xs text-gray-400 font-normal">({allEvents.length})</span>
</h3>
{allEvents.length > 0 ? (
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
{allEvents.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 px-2 py-2 rounded-md hover:bg-gray-50 group">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 flex-shrink-0">{ev.date.slice(5)}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}>
{EVENT_TYPE_LABELS[ev.type] || ev.type}
</span>
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{groupedEvents.length > 0 ? groupedEvents.map(group => {
const GIcon = group.icon
return (
<div key={group.key}>
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-md ${group.bg} mb-1 sticky top-0`}>
<GIcon className={`w-3.5 h-3.5 ${group.color}`} />
<span className={`text-xs font-medium ${group.color}`}>{group.label}</span>
<span className="text-xs text-gray-400">({group.items.length})</span>
</div>
<div className="text-xs text-gray-800 mt-0.5 truncate">
{ev.title}
{ev.employeeName && <span className="text-gray-400 ml-1"> {ev.employeeName}</span>}
<div className="space-y-1">
{group.items.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 group">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 flex-shrink-0">{ev.date.slice(5)}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}>
{EVENT_TYPE_LABELS[ev.type] || ev.type}
</span>
{group.key === 'overdue' && (
<span className="text-[10px] text-red-600 font-medium">{ev.overdueDays}</span>
)}
</div>
<div className="text-xs text-gray-800 mt-0.5 truncate">
{ev.title}
{ev.employeeName && <span className="text-gray-400 ml-1"> {ev.employeeName}</span>}
</div>
{ev.type === 'CONTRACT_EXPIRY' && ev.employeeName ? (
<Link to={`/roster?employee=${encodeURIComponent(ev.employeeName)}`} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
) : ev.actionUrl && ev.actionUrl !== '/dashboard' ? (
<Link to={ev.actionUrl} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
) : null}
</div>
{isCustomEvent(ev) && customEventMap[ev.id] && (
<button
onClick={() => deleteEventMutation.mutate(ev.id)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
{ev.actionUrl && ev.actionUrl !== '/dashboard' && (
<Link to={ev.actionUrl} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
)}
</div>
{isCustomEvent(ev) && customEventMap[ev.id] && (
<button
onClick={() => deleteEventMutation.mutate(ev.id)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
)
}) : (
<div className="text-xs text-gray-500 text-center py-8">35</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-8"></div>
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Shield } from 'lucide-react'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import { commercialInsuranceApi } from '../lib/api-services'
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const INSURANCE_TYPES: Record<string, { label: string; color: string }> = {
ACCIDENT: { label: '意外伤害险', color: 'bg-orange-50 text-orange-700 border border-orange-200' },
SUPPLEMENTARY_MEDICAL: { label: '补充医疗保险', color: 'bg-blue-50 text-blue-700 border border-blue-200' },
EMPLOYER_LIABILITY: { label: '雇主责任险', color: 'bg-purple-50 text-purple-700 border border-purple-200' },
CRITICAL_ILLNESS: { label: '重大疾病险', color: 'bg-rose-50 text-rose-700 border border-rose-200' },
GROUP_LIFE: { label: '团体寿险', color: 'bg-teal-50 text-teal-700 border border-teal-200' },
OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' },
}
export default function CommercialInsurance() {
const [tab, setTab] = useState<'plans' | 'summary'>('plans')
const { data: employeeSummary = [] } = useQuery<any[]>({
queryKey: ['commercial-insurance-employee-summary'],
queryFn: async () => {
return await commercialInsuranceApi.employeeSummary()
},
enabled: tab === 'summary',
})
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield 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>
</div>
</div>
<PageGuide>
<span className="text-primary"> </span>
</PageGuide>
{/* Tab 切换 */}
<div className="flex items-center gap-4 border-b">
{(['plans', 'summary'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setTab(t)}
>
{t === 'plans' ? '方案管理' : '员工汇总'}
</button>
))}
</div>
{/* ========== 方案管理 Tab ========== */}
{tab === 'plans' && <CommercialInsuranceTab />}
{/* ========== 员工汇总 Tab ========== */}
{tab === 'summary' && (
<Card>
{employeeSummary.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{employeeSummary.map((e: any) => (
<tr key={e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3 font-medium">{e.name}</td>
<td className="py-2 px-3 text-gray-500">{e.department}</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{e.insurances.map((ins: any, i: number) => {
const typeCfg = INSURANCE_TYPES[ins.type] || INSURANCE_TYPES.OTHER
return (
<span key={i} className={`px-1.5 py-0.5 rounded text-xs ${typeCfg.color}`}>
{ins.planName} · {ins.provider} · ¥{fmt(ins.premium)}
</span>
)
})}
</div>
</td>
<td className="py-2 px-3 text-right font-medium text-primary">¥{fmt(e.totalPremium)}</td>
<td className="py-2 px-3 text-right font-medium">¥{fmt(e.totalCoverage)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="py-2 px-3" colSpan={3}>{employeeSummary.length}</td>
<td className="py-2 px-3 text-right text-primary">
¥{fmt(employeeSummary.reduce((sum: number, e: any) => sum + e.totalPremium, 0))}
</td>
<td className="py-2 px-3 text-right">
¥{fmt(employeeSummary.reduce((sum: number, e: any) => sum + e.totalCoverage, 0))}
</td>
</tr>
</tfoot>
</table>
</div>
)}
</Card>
)}
</div>
)
}
+1 -1
View File
@@ -118,7 +118,7 @@ export default function CompanyFiles() {
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32 text-xs">
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="!w-28 text-xs">
<option value=""></option>
{FILE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
+16 -7
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download } from 'lucide-react'
import { toast } from 'sonner'
@@ -37,8 +38,8 @@ export default function Contracts() {
const [search, setSearch] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
@@ -60,6 +61,7 @@ export default function Contracts() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
},
})
@@ -76,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)}>
@@ -185,7 +187,7 @@ export default function Contracts() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
@@ -218,6 +220,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const [form, setForm] = useState({
name: '',
department: '',
position: '',
hireDate: '',
monthlySalary: '',
gender: '男' as '男' | '女',
@@ -238,6 +241,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const data: any = {
name: form.name,
department: form.department,
position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary,
gender: form.gender,
@@ -281,14 +285,19 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>/</Label>
<Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
<div className="grid grid-cols-2 gap-3">
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
@@ -39,8 +40,8 @@ function TodoIcon({ type }: { type: string; level: string }) {
}
export default function Dashboard() {
const todoPageSize = usePageSize()
const [todoPage, setTodoPage] = useState(1)
const [todoPageSize, setTodoPageSize] = useState(10)
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'risk' | 'task' | 'cost' | 'workforce'>('overview')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -1128,7 +1129,6 @@ export default function Dashboard() {
</>
)}
</div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
<div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
<div
@@ -1196,6 +1196,7 @@ export default function Dashboard() {
</div>
))}
</div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
</>
)}
</Card>
+253
View File
@@ -0,0 +1,253 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react'
import { esignApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import Modal from '../components/ui/Modal'
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' },
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' },
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500' },
}
const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
CONTRACT: { label: '劳动合同', color: 'bg-blue-50 text-blue-600 border border-blue-200' },
RESIGNATION: { label: '离职协议', color: 'bg-orange-50 text-orange-600 border border-orange-200' },
POLICY: { label: '规章制度', color: 'bg-amber-50 text-amber-600 border border-amber-200' },
PAYSLIP: { label: '工资条', color: 'bg-emerald-50 text-emerald-600 border border-emerald-200' },
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
}
export default function ESign() {
const queryClient = useQueryClient()
const [filterStatus, setFilterStatus] = useState('')
const [filterScene, setFilterScene] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [formData, setFormData] = useState({
employeeId: '',
documentTitle: '',
remark: '',
})
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['esign-records', filterStatus, filterScene],
queryFn: async () => {
return await esignApi.list({ status: filterStatus || undefined, scene: filterScene || undefined })
},
})
const { data: rosterData = [] } = useQuery<any[]>({
queryKey: ['employees-for-esign'],
queryFn: async () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showCreate,
})
const createMutation = useMutation({
mutationFn: async (data: { employeeId: string; documentTitle: string; remark?: string }) =>
esignApi.create(data) as any,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowCreate(false)
setFormData({ employeeId: '', documentTitle: '', remark: '' })
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接')
},
onError: () => toast.error('创建失败'),
})
const cancelMutation = useMutation({
mutationFn: (id: string) => esignApi.cancel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('已取消签署')
},
})
const refreshStatusMutation = useMutation({
mutationFn: (id: string) => esignApi.status(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('状态已刷新')
},
})
const handleCreate = () => {
if (!formData.employeeId) { toast.error('请选择员工'); return }
if (!formData.documentTitle.trim()) { toast.error('请填写文件标题'); return }
createMutation.mutate(formData)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PenTool 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>
</div>
</div>
<PageGuide>
线PDF文件
<span className="text-amber-600"> API后将自动启用在线签署功能</span>
</PageGuide>
<InlineAlert type="info" className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<span className="font-medium">API</span>
<div className="mt-1 text-xs">
AppIdAppSecret API
HR在系统发起签署 PDF
</div>
</div>
</InlineAlert>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{Object.entries(STATUS_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filterScene}
onChange={(e) => setFilterScene(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 签署记录列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : records.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => {
const statusCfg = STATUS_CONFIG[r.status] || STATUS_CONFIG.PENDING
return (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<div className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="font-medium truncate max-w-[200px]">{r.documentTitle}</span>
{r.scene && SCENE_CONFIG[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs shrink-0 ${SCENE_CONFIG[r.scene].color}`}>{SCENE_CONFIG[r.scene].label}</span>
)}
</div>
{r.remark && <div className="text-xs text-gray-400 mt-0.5">{r.remark}</div>}
</td>
<td className="py-2 px-3">{r.employee?.name || '—'}</td>
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span>
</td>
<td className="py-2 px-3 text-gray-500 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
<td className="py-2 px-3 text-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
<td className="py-2 px-3 text-right">
<div className="flex items-center justify-end gap-1">
{r.status === 'COMPLETED' && r.signedPdfUrl && (
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-0.5">
<ExternalLink className="w-3 h-3" />PDF
</a>
)}
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
<>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => refreshStatusMutation.mutate(r.id)}
title="刷新状态">
<RefreshCw className={`w-3.5 h-3.5 ${refreshStatusMutation.isPending ? 'animate-spin' : ''}`} />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={() => {
if (confirm('确定取消此签署任务吗?')) cancelMutation.mutate(r.id)
}}></button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Card>
{/* 发起签署 Modal */}
{showCreate && (
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
<div>
<Label> *</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={formData.employeeId}
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
>
<option value=""></option>
{rosterData.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label> *</Label>
<Input value={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
placeholder="如:2024年度劳动合同" />
</div>
<div>
<Label></Label>
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
placeholder="可选" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}></Button>
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? '创建中...' : '发起签署'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
+459
View File
@@ -0,0 +1,459 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Gift, Plus, Settings as SettingsIcon, X, Users } from 'lucide-react'
import { benefitApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import Modal from '../components/ui/Modal'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const BENEFIT_CATEGORIES: Record<string, { label: string; color: string }> = {
TRANSPORT: { label: '交通补贴', color: 'bg-blue-50 text-blue-700 border border-blue-200' },
MEAL: { label: '餐补', color: 'bg-orange-50 text-orange-700 border border-orange-200' },
HOUSING: { label: '住房补贴', color: 'bg-teal-50 text-teal-700 border border-teal-200' },
COMMUNICATION: { label: '通讯补贴', color: 'bg-purple-50 text-purple-700 border border-purple-200' },
HEALTH_CHECK: { label: '体检', color: 'bg-green-50 text-green-700 border border-green-200' },
HOLIDAY: { label: '节日福利', color: 'bg-red-50 text-red-700 border border-red-200' },
BIRTHDAY: { label: '生日福利', color: 'bg-pink-50 text-pink-700 border border-pink-200' },
OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' },
}
const FREQUENCY_LABELS: Record<string, string> = {
MONTHLY: '每月',
QUARTERLY: '每季',
YEARLY: '每年',
ONE_TIME: '一次性',
}
const DEFAULT_PLAN = {
name: '', category: 'TRANSPORT', amount: 0, frequency: 'MONTHLY',
taxDeductible: false, description: '',
}
export default function EmployeeBenefits() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'plans' | 'summary'>('plans')
const [showAddPlan, setShowAddPlan] = useState(false)
const [editingPlan, setEditingPlan] = useState<any>(null)
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
const [newPlan, setNewPlan] = useState<any>({ ...DEFAULT_PLAN })
const [showEnrollModal, setShowEnrollModal] = useState(false)
const [enrollEmployeeIds, setEnrollEmployeeIds] = useState<string[]>([])
const [enrollEffectiveFrom, setEnrollEffectiveFrom] = useState(new Date().toISOString().slice(0, 7))
const { data: plans = [], isLoading } = useQuery<any[]>({
queryKey: ['benefit-plans'],
queryFn: async () => {
return await benefitApi.plans()
},
})
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
queryKey: ['benefit-enrollments', selectedPlanId],
queryFn: async () => {
if (!selectedPlanId) return []
return await benefitApi.enrollments(selectedPlanId)
},
enabled: !!selectedPlanId,
})
const { data: employeeSummary = [] } = useQuery<any[]>({
queryKey: ['benefit-employee-summary'],
queryFn: async () => {
return await benefitApi.employeeSummary()
},
enabled: tab === 'summary',
})
const { data: rosterData } = useQuery<any[]>({
queryKey: ['employees-for-benefit'],
queryFn: async () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showEnrollModal,
})
const savePlanMutation = useMutation({
mutationFn: async (data: any) => {
if (editingPlan) {
return benefitApi.savePlan(data, editingPlan.id) as any
}
return benefitApi.savePlan(data) as any
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-plans'] })
setShowAddPlan(false)
setEditingPlan(null)
setNewPlan({ ...DEFAULT_PLAN })
toast.success(editingPlan ? '福利方案已更新' : '福利方案已创建')
},
onError: () => toast.error('保存失败'),
})
const deletePlanMutation = useMutation({
mutationFn: (id: string) => benefitApi.removePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-plans'] })
setSelectedPlanId(null)
toast.success('福利方案已删除')
},
})
const enrollMutation = useMutation({
mutationFn: async (data: { employeeIds: string[]; effectiveFrom: string }) =>
benefitApi.enroll(selectedPlanId!, data) as any,
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] })
setShowEnrollModal(false)
setEnrollEmployeeIds([])
toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`)
},
onError: () => toast.error('参保失败'),
})
const terminateMutation = useMutation({
mutationFn: (enrollmentId: string) => benefitApi.terminateEnrollment(enrollmentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] })
toast.success('已终止福利')
},
})
const handleEdit = (plan: any) => {
setEditingPlan(plan)
setNewPlan({ ...plan })
setShowAddPlan(true)
}
const handleSave = () => {
if (!newPlan.name?.trim()) { toast.error('请填写方案名称'); return }
savePlanMutation.mutate(newPlan)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Gift 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>
</div>
</div>
<PageGuide>
<span className="text-primary"> /</span>
</PageGuide>
{/* Tab 切换 */}
<div className="flex items-center gap-4 border-b">
{(['plans', 'summary'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setTab(t)}
>
{t === 'plans' ? '福利方案' : '员工汇总'}
</button>
))}
{tab === 'plans' && (
<Button size="sm" className="ml-auto" onClick={() => { setEditingPlan(null); setNewPlan({ ...DEFAULT_PLAN }); setShowAddPlan(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
)}
</div>
{/* ========== 福利方案 Tab ========== */}
{tab === 'plans' && (
<>
{isLoading ? (
<Card><div className="text-center py-8 text-gray-400">...</div></Card>
) : plans.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan: any) => {
const catCfg = BENEFIT_CATEGORIES[plan.category] || BENEFIT_CATEGORIES.OTHER
const isSelected = selectedPlanId === plan.id
return (
<Card
key={plan.id}
className={`cursor-pointer transition-all ${isSelected ? 'ring-2 ring-primary/20' : 'hover:shadow-md'}`}
>
<div onClick={() => setSelectedPlanId(isSelected ? null : plan.id)}>
<div className="flex items-start justify-between mb-2">
<div>
<span className={`px-2 py-0.5 rounded text-xs ${catCfg.color}`}>{catCfg.label}</span>
<h3 className="text-sm font-medium mt-1">{plan.name}</h3>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => handleEdit(plan)}>
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认删除', message: `确定删除福利方案「${plan.name}」吗?` })) {
deletePlanMutation.mutate(plan.id)
}
}}>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-500">
<div className="flex justify-between"><span></span><span className="text-gray-700">¥{fmt(plan.amount)} / {FREQUENCY_LABELS[plan.frequency] || plan.frequency}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.taxDeductible ? '是' : '否'}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan._count?.enrollments || 0} </span></div>
</div>
{plan.description && <p className="text-xs text-gray-400 mt-2 line-clamp-2">{plan.description}</p>}
</div>
</Card>
)
})}
</div>
)}
{/* 参保人员 */}
{selectedPlanId && (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">{enrollments.length}</h3>
<Button size="sm" variant="secondary" onClick={() => { setEnrollEmployeeIds([]); setShowEnrollModal(true) }}>
<Users className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{enrollLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : enrollments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<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-left"></th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{enrollments.map((e: any) => (
<tr key={e.id || e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveFrom || '—'}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveTo || '至今'}</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{e.status === 'ACTIVE' ? '有效' : '已终止'}
</span>
</td>
<td className="py-2 text-right">
{e.status === 'ACTIVE' && (
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认终止', message: `确定终止${e.name}的福利吗?` })) {
terminateMutation.mutate(e.id)
}
}}></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
</>
)}
{/* ========== 员工汇总 Tab ========== */}
{tab === 'summary' && (
<Card>
{employeeSummary.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<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-right"></th>
</tr>
</thead>
<tbody>
{employeeSummary.map((e: any) => (
<tr key={e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2">
<div className="flex flex-wrap gap-1">
{e.benefits.map((b: any, i: number) => (
<span key={i} className={`px-1.5 py-0.5 rounded text-xs ${BENEFIT_CATEGORIES[b.category]?.color || BENEFIT_CATEGORIES.OTHER.color}`}>
{b.planName} ¥{fmt(b.amount)}
</span>
))}
</div>
</td>
<td className="py-2 text-right font-medium text-primary">¥{fmt(e.totalMonthly)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新增/编辑方案 Modal */}
{showAddPlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
<Card className="w-full max-w-lg mx-4">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">{editingPlan ? '编辑福利方案' : '新增福利方案'}</h3>
<button onClick={() => setShowAddPlan(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={newPlan.name} onChange={(e) => setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度交通补贴" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.category}
onChange={(e) => setNewPlan({ ...newPlan, category: e.target.value })}
>
{Object.entries(BENEFIT_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.frequency}
onChange={(e) => setNewPlan({ ...newPlan, frequency: e.target.value })}
>
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={newPlan.amount} onChange={(e) => setNewPlan({ ...newPlan, amount: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.taxDeductible ? 'true' : 'false'}
onChange={(e) => setNewPlan({ ...newPlan, taxDeductible: e.target.value === 'true' })}
>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
</div>
<div>
<Label></Label>
<Input value={newPlan.description || ''} onChange={(e) => setNewPlan({ ...newPlan, description: e.target.value })} placeholder="适用条件、发放规则等" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowAddPlan(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={savePlanMutation.isPending}>
{savePlanMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</div>
</Card>
</div>
)}
{/* 批量参保 Modal */}
{showEnrollModal && (
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="为员工添加福利" size="lg">
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
<div className="flex items-center gap-2">
<Label className="shrink-0"></Label>
<Input type="month" value={enrollEffectiveFrom} onChange={(e) => setEnrollEffectiveFrom(e.target.value)} className="!w-32" />
</div>
<div className="max-h-80 overflow-y-auto border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left w-8">
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
onChange={(e) => {
if (e.target.checked) {
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
} else {
setEnrollEmployeeIds([])
}
}} />
</th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
</tr>
</thead>
<tbody>
{rosterData?.map((emp: any) => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<input type="checkbox" checked={enrollEmployeeIds.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) setEnrollEmployeeIds([...enrollEmployeeIds, emp.id])
else setEnrollEmployeeIds(enrollEmployeeIds.filter((id) => id !== emp.id))
}} />
</td>
<td className="py-2 px-3 font-medium">{emp.name}</td>
<td className="py-2 px-3 text-gray-500">{emp.department}</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"> {enrollEmployeeIds.length} </span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowEnrollModal(false)}></Button>
<Button size="sm" onClick={() => enrollMutation.mutate({ employeeIds: enrollEmployeeIds, effectiveFrom: enrollEffectiveFrom })}
disabled={enrollEmployeeIds.length === 0 || enrollMutation.isPending}>
{enrollMutation.isPending ? '参保中...' : '确认参保'}
</Button>
</div>
</div>
</div>
</Modal>
)}
</div>
)
}
+17 -3
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery } from '@tanstack/react-query'
import { ShieldCheck, FileText, CheckCircle, XCircle } from 'lucide-react'
import { evidenceApi } from '../lib/api-services'
@@ -13,8 +14,8 @@ import QueryError from '../components/ui/QueryError'
*/
export default function Evidence() {
const [refType, setRefType] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['evidence', refType, page, pageSize],
@@ -66,10 +67,23 @@ export default function Evidence() {
)}
<span className="text-sm font-medium">
{verifyResult?.invalid === 0
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改`
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改${verifyResult?.repaired ? `(自动修复 ${verifyResult.repaired} 条历史数据)` : ''}`
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
</span>
</div>
{verifyResult?.invalidItems?.length > 0 && (
<div className="mt-3 space-y-1.5">
{verifyResult.invalidItems.map((item: any) => (
<div key={item.id} className="flex items-center justify-between px-3 py-2 rounded bg-red-50 border border-red-200 text-sm">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-500 shrink-0" />
<span className="text-red-700">{item.description}</span>
</div>
<span className="text-xs text-gray-400">{new Date(item.createdAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
)}
</Card>
)}
@@ -128,7 +142,7 @@ export default function Evidence() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</div>
)
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
@@ -30,8 +31,8 @@ const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
export default function LeaveApproval() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterStatus, setFilterStatus] = useState('')
const [filterType, setFilterType] = useState('')
const [approveModal, setApproveModal] = useState<{ id: string; action: string; name: string } | null>(null)
@@ -143,7 +144,6 @@ export default function LeaveApproval() {
<EmptyState title="暂无休假申请" description="员工在手机端提交的休假申请将显示在此处" />
) : (
<>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -220,6 +220,7 @@ export default function LeaveApproval() {
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
+5 -2
View File
@@ -1,4 +1,5 @@
import { useState, lazy, Suspense } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react'
const BatchManager = lazy(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
@@ -9,7 +10,9 @@ const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ defa
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('batch')
const [searchParams] = useSearchParams()
const initialEmployeeId = searchParams.get('employeeId') || ''
const [tab, setTab] = useState<Tab>(initialEmployeeId ? 'payslip' : 'batch')
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
@@ -47,7 +50,7 @@ export default function Money() {
{tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'payslip' && <PayslipManager />}
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
</Suspense>
</div>
)
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Bell, CheckCircle, AlertCircle, Send, Settings as SettingsIcon, X } from 'lucide-react'
@@ -32,8 +33,8 @@ const CHANNEL_LABELS: Record<string, string> = {
*/
export default function Notifications() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showSettings, setShowSettings] = useState(false)
const { data, isLoading } = useQuery<any>({
@@ -141,7 +142,7 @@ export default function Notifications() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
+294
View File
@@ -0,0 +1,294 @@
/**
*
* +
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Building2, Briefcase } from 'lucide-react'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import api from '../lib/api'
export default function OrgChart() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<'dept' | 'position'>('dept')
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<any>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [form, setForm] = useState<any>({ name: '', parentId: '', sortOrder: 0, description: '' })
const { data: departments = [], isLoading } = useQuery({
queryKey: ['departments'],
queryFn: () => api.get('/departments').then(r => r.data.data),
})
const { data: positions = [] } = useQuery({
queryKey: ['positions'],
queryFn: () => api.get('/positions').then(r => r.data.data),
enabled: tab === 'position',
})
const createMutation = useMutation({
mutationFn: (data: any) => api.post(editing ? `/departments/${editing.id}` : '/departments', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] })
setShowModal(false)
toast.success(editing ? '部门已更新' : '部门已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/departments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['departments'] })
toast.success('部门已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
const toggleExpand = (id: string) => {
setExpanded(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const renderTree = (items: any[], parentId: string | null, level: number = 0) => {
return items.filter(d => d.parentId === parentId).map(d => (
<div key={d.id}>
<div
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 rounded-md"
style={{ marginLeft: level * 24 }}
>
{items.some(c => c.parentId === d.id) ? (
<button onClick={() => toggleExpand(d.id)} className="text-gray-400 hover:text-gray-600">
{expanded.has(d.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
) : (
<span className="w-4" />
)}
<Building2 className="w-4 h-4 text-primary" />
<span className="flex-1 text-sm">{d.name}</span>
<span className="text-xs text-gray-400">{d._count?.employees || 0}</span>
<button
onClick={() => {
setEditing(d)
setForm({ name: d.name, parentId: d.parentId || '', sortOrder: d.sortOrder, description: d.description || '' })
setShowModal(true)
}}
className="text-gray-400 hover:text-primary"
>
<Edit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { if (confirm(`确认删除部门"${d.name}"`)) deleteMutation.mutate(d.id) }}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{expanded.has(d.id) && renderTree(items, d.id, level + 1)}
</div>
))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
<div className="flex gap-2 border-b">
<button
onClick={() => setTab('dept')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'dept' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
>
</button>
<button
onClick={() => setTab('position')}
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'position' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
>
</button>
</div>
{tab === 'dept' ? (
<Card className="p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium"></span>
<Button size="sm" onClick={() => {
setEditing(null)
setForm({ name: '', parentId: '', sortOrder: 0, description: '' })
setShowModal(true)
}}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">...</div>
) : departments.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-1">{renderTree(departments, null)}</div>
)}
</Card>
) : (
<PositionTab positions={positions} departments={departments} />
)}
{showModal && (
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:技术部" />
</div>
<div>
<Label></Label>
<Select value={form.parentId} onChange={(e) => setForm({ ...form, parentId: e.target.value })}>
<option value=""></option>
{departments.filter((d: any) => d.id !== editing?.id).map((d: any) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowModal(false)}></Button>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
{editing ? '保存' : '创建'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
/** 岗位字典子组件 */
function PositionTab({ positions, departments }: { positions: any[]; departments: any[] }) {
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<any>(null)
const [form, setForm] = useState<any>({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(editing ? `/positions/${editing.id}` : '/positions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
setShowModal(false)
toast.success(editing ? '岗位已更新' : '岗位已创建')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/positions/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] })
toast.success('岗位已删除')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
})
return (
<Card className="p-4">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium"></span>
<Button size="sm" onClick={() => {
setEditing(null)
setForm({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
setShowModal(true)
}}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{positions.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="space-y-1">
{positions.map(p => (
<div key={p.id} className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 rounded-md">
<Briefcase className="w-4 h-4 text-primary" />
<span className="flex-1 text-sm">{p.name}</span>
{p.department && <span className="text-xs text-gray-400">{p.department.name}</span>}
{p.level && <span className="text-xs text-gray-400">{p.level}</span>}
<span className="text-xs text-gray-400">{p.headcount}</span>
<button
onClick={() => {
setEditing(p)
setForm({ name: p.name, departmentId: p.departmentId || '', headcount: p.headcount, level: p.level || '', description: p.description || '' })
setShowModal(true)
}}
className="text-gray-400 hover:text-primary"
>
<Edit className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { if (confirm(`确认删除岗位"${p.name}"`)) deleteMutation.mutate(p.id) }}
className="text-gray-400 hover:text-danger"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{showModal && (
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑岗位' : '新增岗位'}>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:前端工程师" />
</div>
<div>
<Label></Label>
<Select value={form.departmentId} onChange={(e) => setForm({ ...form, departmentId: e.target.value })}>
<option value=""></option>
{departments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.headcount} onChange={(e) => setForm({ ...form, headcount: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input value={form.level} onChange={(e) => setForm({ ...form, level: e.target.value })} placeholder="如 P1/M1" />
</div>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowModal(false)}></Button>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
{editing ? '保存' : '创建'}
</Button>
</div>
</div>
</Modal>
)}
</Card>
)
}
+65 -7
View File
@@ -1,7 +1,9 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell, Upload } from 'lucide-react'
import mammoth from 'mammoth'
import { policiesApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -26,8 +28,8 @@ export default function Policies() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['policies', page, pageSize],
@@ -130,7 +132,7 @@ export default function Policies() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
{/* 详情弹窗 */}
@@ -200,6 +202,7 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [type, setType] = useState('RULES')
const fileInputRef = useRef<HTMLInputElement>(null)
const createMutation = useMutation({
mutationFn: () => policiesApi.create({ title, content, type }),
@@ -229,7 +232,32 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
</div>
<div>
<label className="text-xs text-gray-600"></label>
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
<div className="flex items-center gap-2 mt-1 mb-1">
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-3.5 h-3.5 mr-1" /> Word
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.convertToHtml({ arrayBuffer })
setContent(result.value)
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文或导入 Word 文档..." />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
@@ -246,6 +274,8 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
*
*/
function ReadStats({ policyId }: { policyId: string }) {
const queryClient = useQueryClient()
const [showUnread, setShowUnread] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
@@ -253,6 +283,17 @@ function ReadStats({ policyId }: { policyId: string }) {
},
})
const remindMutation = useMutation({
mutationFn: async (employeeIds?: string[]) => {
return await policiesApi.remind(policyId, employeeIds)
},
onSuccess: (res: any) => {
toast.success(`已催办 ${res?.reminded || 0} 名未签收员工`)
queryClient.invalidateQueries({ queryKey: ['policy-read-stats', policyId] })
},
onError: () => toast.error('催办失败'),
})
if (isLoading) return <div className="text-xs text-gray-400 mt-3">...</div>
if (!data) return null
@@ -270,8 +311,25 @@ function ReadStats({ policyId }: { policyId: string }) {
</span>
</div>
{data.unreadCount > 0 && (
<div className="text-xs text-amber-600 mb-2">
{data.unreadCount}
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-amber-600">{data.unreadCount} </span>
<button onClick={() => setShowUnread(!showUnread)} className="text-xs text-primary hover:underline">
{showUnread ? '收起' : '查看明细'}
</button>
<Button size="sm" variant="secondary" className="!h-6 !px-2 !text-xs" onClick={() => remindMutation.mutate(undefined)} disabled={remindMutation.isPending}>
<Bell className="w-3 h-3 mr-1" />
</Button>
</div>
)}
{showUnread && data.unreadEmployees && data.unreadEmployees.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1 mb-2">
{data.unreadEmployees.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-amber-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-amber-600"></span>
</div>
))}
</div>
)}
{data.records && data.records.length > 0 && (
+323 -66
View File
@@ -1,10 +1,13 @@
import { useState, useMemo, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
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 } 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'
import Card from '../components/ui/Card'
@@ -16,14 +19,51 @@ 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'
const ROSTER_COLUMNS = [
{ key: 'department', label: '部门' },
{ key: 'status', label: '状态' },
{ key: 'hireDate', label: '入职日期' },
{ key: 'position', label: '职务' },
{ key: 'phone', label: '手机号' },
{ key: 'gender', label: '性别' },
{ key: 'contractType', label: '合同类型' },
{ key: 'contractStatus', label: '合同状态' },
{ key: 'contractExpiry', label: '合同到期' },
{ key: 'socialStatus', label: '社保状态' },
{ key: 'socialInsBase', label: '社保基数' },
{ key: 'socialInsAmount', label: '社保缴费' },
{ key: 'records', label: '记录' },
] as const
const DEFAULT_VISIBLE = ['department', 'status', 'hireDate', 'position', 'phone', 'gender', 'contractType', 'contractStatus', 'contractExpiry', 'socialStatus']
function useRosterColumns() {
const [visible, setVisible] = useState<string[]>(() => {
try {
const saved = localStorage.getItem('roster-columns')
return saved ? JSON.parse(saved) : DEFAULT_VISIBLE
} catch { return DEFAULT_VISIBLE }
})
const toggle = (key: string) => {
setVisible(prev => {
const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
localStorage.setItem('roster-columns', JSON.stringify(next))
return next
})
}
const isVisible = (key: string) => visible.includes(key)
return { isVisible, toggle, visible }
}
export default function Roster() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
@@ -37,8 +77,10 @@ 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 [pageSize, setPageSize] = useState(20)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
const [batchRenewYears, setBatchRenewYears] = useState(3)
@@ -71,9 +113,16 @@ export default function Roster() {
})
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
// 从 URL query 参数自动定位员工(从待办跳转时)
// 从 URL query 参数自动定位员工(从待办跳转或绩效/培训列表跳转时)
const employeeParam = searchParams.get('employee')
const employeeIdParam = searchParams.get('employeeId')
useEffect(() => {
// 优先按 employeeId 直接定位
if (employeeIdParam && !selectedId) {
setSelectedId(employeeIdParam)
setSearchParams({}, { replace: true })
return
}
if (!employeeParam) return
setSearch(employeeParam)
// 只有当 debouncedSearch 与参数一致时,搜索结果才真正匹配
@@ -82,7 +131,7 @@ export default function Roster() {
// 清除 URL 参数,避免后续搜索时重复触发
setSearchParams({}, { replace: true })
}
}, [employeeParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
const addMutation = useMutation({
mutationFn: (data: any) => employeeApi.create(data),
@@ -90,8 +139,13 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
toast.success('员工已添加', {
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
})
},
onError: (err: any) => toastError(err, '创建失败'),
})
const resignMutation = useMutation({
@@ -108,7 +162,9 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程')
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', {
action: { label: '前往处理', onClick: () => navigate('/termination') },
})
setShowResignModal(false)
setResignEmployee(null)
},
@@ -156,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: () => {
@@ -210,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)
@@ -264,10 +378,14 @@ export default function Roster() {
},
})
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
const [showColSettings, setShowColSettings] = useState(false)
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search) || (e.idCardMasked && e.idCardMasked.includes(search))
) || []
// 通讯录视图数据
if (selectedId) {
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
}
@@ -275,7 +393,7 @@ export default function Roster() {
return (
<div className="space-y-5">
<PageGuide>
</PageGuide>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
@@ -330,6 +448,9 @@ export default function Roster() {
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => navigate('/work-process')} className="h-9 shrink-0" title="完整的入职办理流程">
<UserPlus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
@@ -355,6 +476,30 @@ export default function Roster() {
}} className="h-9 shrink-0">
<Download className="mr-1.5 h-4 w-4" />
</Button>
<div className="relative shrink-0">
<Button variant="secondary" onClick={() => setShowColSettings(v => !v)} className="h-9">
<Settings2 className="mr-1.5 h-4 w-4" />
</Button>
{showColSettings && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowColSettings(false)} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-lg border border-gray-200 bg-white shadow-lg py-2">
<div className="px-3 pb-1 text-xs font-medium text-gray-400"></div>
{ROSTER_COLUMNS.map(col => (
<label key={col.key} className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={colVisible(col.key)}
onChange={() => colToggle(col.key)}
className="rounded border-gray-300"
/>
<span className="text-gray-700">{col.label}</span>
</label>
))}
</div>
</>
)}
</div>
</div>
</div>
@@ -365,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>
@@ -418,37 +569,28 @@ export default function Roster() {
<Card><div className="py-12 text-center text-sm text-gray-400"></div></Card>
) : (
<Card className="overflow-hidden p-0">
<div className="border-b border-gray-100 px-5">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] text-sm">
<table className="w-full min-w-[900px] text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left w-8">
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
{colVisible('department') && <th className="px-4 py-3 text-left"></th>}
{colVisible('status') && <th className="px-4 py-3 text-left"></th>}
{colVisible('hireDate') && <th className="px-4 py-3 text-left"></th>}
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
{colVisible('position') && <th className="px-4 py-3 text-left"></th>}
{colVisible('phone') && <th className="px-4 py-3 text-left"></th>}
{colVisible('gender') && <th className="px-4 py-3 text-center"></th>}
{colVisible('contractType') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractExpiry') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialInsBase') && <th className="px-4 py-3 text-right"></th>}
{colVisible('socialInsAmount') && <th className="px-4 py-3 text-right"></th>}
{colVisible('records') && <th className="px-4 py-3 text-center"></th>}
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
@@ -462,10 +604,17 @@ export default function Roster() {
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td>
<td className="px-4 py-3 font-medium">{e.name}</td>
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{e.idCardMasked || '—'}</td>
<td className="px-4 py-3 text-gray-500">{e.department}</td>
<td className="px-4 py-3">
<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) => {
ev.stopPropagation()
if (e.idCardNumber) {
copyToClipboard(e.idCardNumber, '已复制证件号码')
}
}}>{e.idCardMasked || '—'}</div>
</td>
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>}
{colVisible('status') && <td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
@@ -482,8 +631,8 @@ export default function Roster() {
{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `${e.probationInfo.daysToConfirm}`}
</span>
)}
</td>
<td className="hidden px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
</td>}
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
<td className="hidden px-4 py-3 text-gray-500">
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
@@ -494,8 +643,10 @@ export default function Roster() {
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3 text-right">¥{fmt(e.monthlySalary)}</td>
<td className="px-4 py-3">
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>}
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>}
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</td>}
{colVisible('contractType') && <td className="px-4 py-3">
{(() => {
const typeConfig: Record<string, { label: string; style: string }> = {
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
@@ -511,8 +662,8 @@ export default function Roster() {
const cfg = typeConfig[ct] || typeConfig.UNSIGNED
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
})()}
</td>
<td className="px-4 py-3">
</td>}
{colVisible('contractStatus') && <td className="px-4 py-3">
{(() => {
const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger',
@@ -536,8 +687,8 @@ export default function Roster() {
const text = statusTextMap[e.contractStatus] || '无合同'
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
})()}
</td>
<td className="hidden px-4 py-3 text-gray-500">
</td>}
{colVisible('contractExpiry') && <td className="px-4 py-3 text-gray-500">
{(() => {
const endDate = e.latestContract?.endDate
if (!endDate) {
@@ -556,16 +707,62 @@ export default function Roster() {
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{dateStr} ({diffDays})</span>
return <span className="text-xs">{dateStr}</span>
})()}
</td>
<td className="px-4 py-3 text-center">
{e.counts?.disciplinaryRecords ? (
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
) : <span className="text-gray-300">0</span>}
</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
</td>}
{colVisible('socialStatus') && <td className="px-4 py-3">
{(() => {
const status = e.socialInsuranceStatus
if (!status) return <span className="text-gray-300 text-xs"></span>
const cfg: Record<string, { label: string; style: string }> = {
ACTIVE: { label: '在保', style: 'bg-green-50 text-safe' },
SUSPENDED: { label: '停保', style: 'bg-amber-50 text-amber-600' },
UNINSURED: { label: '未参保', style: 'bg-red-50 text-danger' },
PENDING: { label: '待办理', style: 'bg-blue-50 text-blue-600' },
}
const c = cfg[status] || { label: status, style: 'bg-gray-100 text-gray-500' }
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
})()}
</td>}
{colVisible('socialInsBase') && <td className="px-4 py-3 text-right text-xs">
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300"></span>}
</td>}
{colVisible('socialInsAmount') && <td className="px-4 py-3 text-right text-xs">
{(() => {
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300"></span>
const socialEmp = e.socialInsCalc?.socialEmp || 0
const socialOrg = e.socialInsCalc?.socialOrg || 0
const housingEmp = e.housingFundCalc?.housingEmp || 0
const housingOrg = e.housingFundCalc?.housingOrg || 0
const totalEmp = socialEmp + housingEmp
const totalOrg = socialOrg + housingOrg
return (
<div className="flex flex-col">
<span>: {totalEmp.toFixed(2)}</span>
<span className="text-gray-400">: {totalOrg.toFixed(2)}</span>
</div>
)
})()}
</td>}
{colVisible('records') && <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 flex-wrap">
{(() => {
const items = [
{ label: '违纪', count: e.counts?.disciplinaryRecords || 0, danger: true },
{ label: '考勤', count: e.counts?.attendanceRecords || 0 },
{ label: '培训', count: e.counts?.trainingRecords || 0 },
{ label: '绩效', count: e.counts?.performanceRecords || 0 },
{ label: '工资条', count: e.counts?.payslips || 0 },
]
return items.map((it, i) => (
<span
key={i}
className={`text-xs px-1.5 py-0.5 rounded ${it.count > 0 ? (it.danger ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-600') : 'text-gray-300'}`}
>
{it.label}{it.count}
</span>
))
})()}
</div>
</td>}
<td className="px-4 py-3 text-center">
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
<div className="flex items-center justify-center gap-2">
@@ -582,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()
window.location.hash = '#/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="调部门"
@@ -607,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="离职"
@@ -667,6 +905,15 @@ export default function Roster() {
</tbody>
</table>
</div>
<div className="border-t border-gray-100 px-5 py-3">
<Pagination
page={pagination.page}
pageSize={pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={() => setPage(1)}
/>
</div>
</Card>
)}
@@ -725,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">
+139 -96
View File
@@ -4,7 +4,7 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3, TrendingUp, TrendingDown, Users, Wallet,
BarChart3, TrendingUp, TrendingDown, Users, Wallet, DollarSign, Gauge,
} from 'lucide-react'
import Card from '../components/ui/Card'
import { Select } from '../components/ui/Input'
@@ -15,11 +15,15 @@ import { salaryDashboardApi } from '../lib/api-services'
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const fmtShort = (n: number) => {
if (!n) return '0'
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return n.toFixed(0)
}
export default function SalaryDashboard() {
const [year, setYear] = useState(new Date().getFullYear().toString())
/** 获取薪酬分析数据 */
const { data, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['salary-dashboard', year],
queryFn: async () => {
@@ -31,17 +35,25 @@ export default function SalaryDashboard() {
const monthlyTrend = data?.monthlyTrend || []
const summary = data?.summary || {}
/** 计算最大值用于柱状图比例 */
const maxDeptAvg = useMemo(() => {
if (departments.length === 0) return 1
return Math.max(...departments.map((d: any) => d.avgSalary || 0), 1)
}, [departments])
const maxMonthly = useMemo(() => {
if (monthlyTrend.length === 0) return 1
return Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
}, [monthlyTrend])
const summaryCards = [
{ icon: Users, label: '员工总数', value: summary.totalEmployees || 0, suffix: '', color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-100' },
{ icon: Wallet, label: '月均薪酬', value: `¥${fmt(summary.avgSalary)}`, suffix: '', color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-100' },
{ icon: Gauge, label: '薪酬中位数', value: `¥${fmt(summary.medianSalary)}`, suffix: '', color: 'text-purple-600', bg: 'bg-purple-50', border: 'border-purple-100' },
{ icon: DollarSign, label: '年度总薪酬', value: `¥${fmt(summary.totalAnnual)}`, suffix: '', color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-100' },
]
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -58,6 +70,11 @@ export default function SalaryDashboard() {
</Select>
</div>
{/* 操作说明 — 紧跟标题下方 */}
<PageGuide>
</PageGuide>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : isError ? (
@@ -68,110 +85,136 @@ export default function SalaryDashboard() {
<>
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="p-3">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">{summary.totalEmployees || 0}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.avgSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.medianSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-amber-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.totalAnnual)}</div>
</Card>
{summaryCards.map((card, i) => {
const Icon = card.icon
return (
<Card key={i} className={`p-4 border ${card.border}`}>
<div className="flex items-center justify-between">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${card.bg}`}>
<Icon className={`w-4 h-4 ${card.color}`} />
</div>
</div>
<div className="text-xs text-gray-400 mt-2">{card.label}</div>
<div className={`text-xl font-bold mt-0.5 ${card.color}`}>{card.value}{card.suffix}</div>
</Card>
)
})}
</div>
{/* 同比环比 */}
{summary.yoy !== undefined && (
<div className="flex gap-3">
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.yoy >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.yoy >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}%
<div className="grid grid-cols-2 gap-3">
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${summary.yoy >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.yoy >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}%
</div>
</div>
</Card>
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.mom >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.mom >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}%
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${summary.mom >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.mom >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}%
</div>
</div>
</Card>
</div>
)}
{/* 部门薪酬对比 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{departments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-3">
{departments.map((dept: any) => (
<div key={dept.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-600">{dept.name}</span>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{dept.count}</span>
<span className="font-medium text-gray-700">¥{fmt(dept.avgSalary)}</span>
</div>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${(dept.avgSalary / maxDeptAvg) * 100}%` }}
/>
</div>
</div>
))}
{/* 部门薪酬对比 + 月度趋势 双栏布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 部门薪酬对比 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{departments.length > 0 && (
<span className="text-xs text-gray-400"> {departments.length} </span>
)}
</div>
)}
</Card>
{departments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-2.5 max-h-[420px] overflow-y-auto pr-1">
{departments.map((dept: any, idx: number) => {
const pct = (dept.avgSalary / maxDeptAvg) * 100
const isTop3 = idx < 3
return (
<div key={dept.name} className="group">
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center gap-2 min-w-0">
{isTop3 && (
<span className={`flex-shrink-0 w-4 h-4 rounded text-[10px] flex items-center justify-center font-bold ${
idx === 0 ? 'bg-amber-100 text-amber-700' : idx === 1 ? 'bg-gray-200 text-gray-600' : 'bg-orange-100 text-orange-700'
}`}>{idx + 1}</span>
)}
<span className="text-gray-600 truncate">{dept.name}</span>
</div>
<div className="flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-gray-400">{dept.count}</span>
<span className="text-gray-400"></span>
<span className="font-semibold text-gray-800">¥{fmt(dept.avgSalary)}</span>
</div>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all group-hover:opacity-80 ${
isTop3 ? 'bg-gradient-to-r from-primary to-primary/70' : 'bg-primary/40'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</Card>
{/* 月度趋势 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="flex items-end gap-2 h-40">
{monthlyTrend.map((m: any) => {
const maxVal = Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
const height = ((m.total || 0) / maxVal) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1">
<div className="text-xs text-gray-400">{m.total ? `¥${(m.total / 10000).toFixed(1)}` : ''}</div>
<div className="w-full bg-gray-100 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-primary/70 rounded-t-md transition-all hover:bg-primary"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-xs text-gray-400">{m.month}</div>
</div>
)
})}
{/* 月度趋势 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{monthlyTrend.length > 0 && (
<span className="text-xs text-gray-400"></span>
)}
</div>
)}
</Card>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="relative">
{/* 网格参考线 */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none">
{[0, 1, 2, 3].map(i => (
<div key={i} className="border-t border-dashed border-gray-100" />
))}
</div>
<div className="flex items-end gap-1.5 h-48 relative">
{monthlyTrend.map((m: any) => {
const height = ((m.total || 0) / maxMonthly) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1 group cursor-pointer">
<div className="text-[10px] text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{m.total ? `¥${fmtShort(m.total)}` : ''}
</div>
<div className="w-full bg-gray-50 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-gradient-to-t from-primary/60 to-primary rounded-t-md transition-all group-hover:from-primary group-hover:to-primary/80"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-[10px] text-gray-400">{m.month}</div>
</div>
)
})}
</div>
</div>
)}
</Card>
</div>
{summary.totalEmployees === 0 && (
<InlineAlert type="info">
+321 -84
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList } from 'lucide-react'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2 } from 'lucide-react'
import { settingsApi, notificationsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -13,7 +14,7 @@ import { useConfirm } from '../hooks/useConfirm'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'import' | 'export'>('org')
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'import' | 'export'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
@@ -39,6 +40,8 @@ export default function Settings() {
{ key: 'users' as const, label: '用户管理', icon: Users },
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
{ key: 'export' as const, label: '数据导出', icon: Download },
]
@@ -77,24 +80,31 @@ export default function Settings() {
{activeSection === 'users' && <UserSettings usersData={usersData} />}
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<ExportSettings />
</Card>
{activeSection === 'retirement' && (
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
)}
{activeSection === 'medical' && <MedicalPeriodSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && <ExportSettings />}
</div>
)
}
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
const [pageSize, setPageSize] = useState(getPageSize())
const [form, setForm] = useState({
name: '',
contactName: '',
contactPhone: '',
payrollFrequency: 1,
payrollDays: [5] as number[],
payrollReminderDays: 3,
retirementReminderEnabled: false,
esignPolicyEnabled: false,
esignPayslipEnabled: false,
esignOnboardingEnabled: false,
esignTrainingEnabled: false,
esignPerformanceEnabled: false,
esignDisciplinaryEnabled: false,
})
useEffect(() => {
@@ -103,8 +113,15 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
name: orgData.name || '',
contactName: orgData.contactName || '',
contactPhone: orgData.contactPhone || '',
payrollFrequency: orgData.payrollFrequency || 1,
payrollDays: Array.isArray(orgData.payrollDays) && orgData.payrollDays.length > 0 ? orgData.payrollDays : [5],
payrollReminderDays: orgData.payrollReminderDays ?? 3,
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
esignPolicyEnabled: orgData.esignPolicyEnabled || false,
esignPayslipEnabled: orgData.esignPayslipEnabled || false,
esignOnboardingEnabled: orgData.esignOnboardingEnabled || false,
esignTrainingEnabled: orgData.esignTrainingEnabled || false,
esignPerformanceEnabled: orgData.esignPerformanceEnabled || false,
esignDisciplinaryEnabled: orgData.esignDisciplinaryEnabled || false,
})
}
}, [orgData])
@@ -126,28 +143,111 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
</div>
<div>
<Label></Label>
<Select value={String(form.payrollFrequency)} onChange={(e) => setForm({ ...form, payrollFrequency: Number(e.target.value) })}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</Select>
<p className="text-xs text-gray-500 mt-1"></p>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<button
key={day}
type="button"
onClick={() => {
const days = form.payrollDays.includes(day)
? form.payrollDays.filter(d => d !== day)
: [...form.payrollDays, day].sort((a, b) => a - b)
setForm({ ...form, payrollDays: days })
}}
className={`w-9 h-9 rounded-md text-xs font-medium border transition-colors ${
form.payrollDays.includes(day)
? 'bg-primary text-white border-primary'
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
}`}
>
{day}
</button>
))}
</div>
{form.payrollDays.length > 0 && (
<p className="text-xs text-gray-500 mt-2"> {form.payrollDays.map(d => `${d}`).join('、')} </p>
)}
</div>
<div>
<Label></Label>
<div className="flex items-center gap-3">
<Input type="number" min={0} max={30} value={form.payrollReminderDays} onChange={(e) => setForm({ ...form, payrollReminderDays: Number(e.target.value) })} className="!w-24" />
<span className="text-xs text-gray-500"></span>
</div>
</div>
<Button onClick={() => onSave(form)} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
<RetirementSection enabled={form.retirementReminderEnabled} onToggle={(v) => { setForm({ ...form, retirementReminderEnabled: v }); onSave({ ...form, retirementReminderEnabled: v }) }} />
{/* 电子签署设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
<PenTool className="w-5 h-5 text-primary" />
<h3 className="font-medium"></h3>
</div>
<div className="grid grid-cols-2 gap-4">
{([
{ key: 'esignPolicyEnabled', label: '规章制度电子签', desc: '开启后,员工阅读规章制度时需电子签署;关闭时保持阅读确认' },
{ key: 'esignPayslipEnabled', label: '工资条电子签', desc: '开启后,员工确认工资条时需电子签署;关闭时保持点击确认' },
{ key: 'esignOnboardingEnabled', label: '入职文件电子签', desc: '开启后,HR审批通过入职流程时自动创建入职文件签署;关闭时不签' },
{ key: 'esignTrainingEnabled', label: '培训记录电子签', desc: '开启后,员工签收培训记录时需电子签署;关闭时保持点击签收' },
{ key: 'esignPerformanceEnabled', label: '绩效考核电子签', desc: '开启后,员工签字确认绩效时需电子签署;关闭时保持点击确认' },
{ key: 'esignDisciplinaryEnabled', label: '违纪记录电子签', desc: '开启后,员工签字确认违纪记录时需电子签署;关闭时保持点击确认' },
] as const).map((item) => (
<div key={item.key} className="flex items-center justify-between border rounded-lg p-3">
<div className="flex-1 mr-3">
<div className="text-sm font-medium">{item.label}</div>
<p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>
</div>
<button
type="button"
onClick={() => {
const next = !form[item.key]
setForm({ ...form, [item.key]: next })
onSave({ [item.key]: next })
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${form[item.key] ? 'bg-primary' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form[item.key] ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
))}
</div>
</div>
{/* 显示设置 */}
<div className="mt-6 pt-6 border-t">
<div className="flex items-center gap-2 mb-4">
<LayoutGrid className="w-5 h-5 text-primary" />
<h3 className="font-medium"></h3>
</div>
<div className="space-y-4">
<div>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex items-center gap-3">
<Select value={String(pageSize)} onChange={(e) => setPageSize(parseInt(e.target.value, 10))} className="!w-32">
<option value="10">10 /</option>
<option value="20">20 /</option>
<option value="50">50 /</option>
<option value="100">100 /</option>
</Select>
<Button size="sm" onClick={() => { setGlobalPageSize(pageSize); toast.success(`分页大小已设置为 ${pageSize} 条/页`) }}></Button>
</div>
</div>
</div>
</div>
</Card>
)
}
function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle: (v: boolean) => void }) {
function RetirementSettings({ orgData, onSave }: { orgData: any; onSave: (data: any) => void }) {
const queryClient = useQueryClient()
const [confirming, setConfirming] = useState(false)
const enabled = orgData?.retirementReminderEnabled || false
const { data: policyData, isLoading } = useQuery<any>({
queryKey: ['retirement-policy'],
@@ -195,7 +295,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
)
return (
<div className="mt-6 pt-6 border-t">
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-primary" />
@@ -214,7 +314,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
<span className="text-sm text-gray-500">{enabled ? '已开启' : '未开启'}</span>
<button
type="button"
onClick={() => onToggle(!enabled)}
onClick={() => onSave({ retirementReminderEnabled: !enabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
@@ -259,7 +359,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
{isLoading && <p className="text-sm text-gray-400 text-center">...</p>}
</div>
)}
</div>
</Card>
)
}
@@ -705,7 +805,6 @@ function PlanSettings({ orgData }: { orgData: any }) {
function NotificationSettings() {
const queryClient = useQueryClient()
const [form, setForm] = useState<any>({})
const [checkResult, setCheckResult] = useState<string>('')
const { data: setting } = useQuery<any>({
queryKey: ['notification-settings'],
@@ -714,13 +813,6 @@ function NotificationSettings() {
},
})
const { data: logsData } = useQuery<any>({
queryKey: ['notification-logs'],
queryFn: async () => {
return await notificationsApi.logs({ pageSize: 10 })
},
})
useEffect(() => {
if (setting) setForm(setting)
}, [setting])
@@ -730,14 +822,6 @@ function NotificationSettings() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
})
const checkMutation = useMutation({
mutationFn: () => notificationsApi.checkContracts() as any,
onSuccess: (res: any) => {
setCheckResult(`检查完成:发现 ${res.data.checked} 个即将到期的合同,已发送 ${res.data.notified} 条通知`)
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
},
})
const testWechatMutation = useMutation({
mutationFn: () => notificationsApi.test('wechat') as any,
onSuccess: (res: any) => {
@@ -752,8 +836,6 @@ function NotificationSettings() {
},
})
const logs = logsData?.items || []
return (
<div className="space-y-3">
<Card>
@@ -782,11 +864,7 @@ function NotificationSettings() {
<div className="border-t pt-3 space-y-3">
<div className="text-xs font-medium"></div>
<div className="text-xs text-gray-500"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.payrollDay ?? 10} onChange={(e) => setForm({ ...form, payrollDay: Number(e.target.value) })} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.socialInsDay ?? 15} onChange={(e) => setForm({ ...form, socialInsDay: Number(e.target.value) })} />
@@ -830,31 +908,6 @@ function NotificationSettings() {
</Button>
</div>
</Card>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => checkMutation.mutate()} disabled={checkMutation.isPending}>
{checkMutation.isPending ? '检查中...' : '立即检查'}
</Button>
</div>
{checkResult && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs mb-3">{checkResult}</div>
)}
{logs.length > 0 ? (
<div className="space-y-2">
{logs.map((log: any) => (
<div key={log.id} className="text-xs border-b last:border-0 py-2">
<div className="font-medium">{log.title}</div>
<div className="text-gray-500 text-xs mt-0.5">{log.content}</div>
<div className="text-gray-500 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-xs text-center py-4"></div>
)}
</Card>
</div>
)
}
@@ -1095,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">
@@ -1161,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>
@@ -1324,14 +1377,198 @@ 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>
)
}
function MedicalPeriodSettings() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editPolicy, setEditPolicy] = useState<any>(null)
const { data: policies = [], isLoading } = useQuery<any[]>({
queryKey: ['medical-period-policies'],
queryFn: () => settingsApi.medicalPeriodPolicies(),
})
const saveMut = useMutation({
mutationFn: (data: any) => settingsApi.saveMedicalPeriodPolicy(data),
onSuccess: () => {
toast.success('政策已保存')
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
setShowForm(false)
setEditPolicy(null)
},
onError: () => toast.error('保存失败'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => settingsApi.deleteMedicalPeriodPolicy(id),
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
},
onError: () => toast.error('删除失败'),
})
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-medium"></h2>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<Button size="sm" onClick={() => { setEditPolicy(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="py-8 text-center text-gray-400 text-sm">...</div>
) : policies.length === 0 ? (
<div className="py-8 text-center text-gray-400 text-sm"></div>
) : (
<div className="space-y-2">
{policies.map((p: any) => (
<div key={p.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{p.region}</span>
{p.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary"></span>}
</div>
<div className="flex gap-1">
<button onClick={() => { setEditPolicy(p); setShowForm(true) }} className="text-xs text-primary hover:underline"></button>
{!p.isDefault && (
<button onClick={() => { if (confirm(`确认删除「${p.region}」政策?`)) deleteMut.mutate(p.id) }} className="p-1 hover:bg-gray-100 rounded">
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
<div className="text-xs text-gray-500 mb-2">{p.legalBasis}</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-400">
<th className="py-1 pr-3 font-medium"></th>
<th className="py-1 pr-3 font-medium"></th>
<th className="py-1 pr-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{p.rules.map((rule: any, idx: number) => {
const prevMax = idx > 0 ? p.rules[idx - 1].maxYears : 0
const isLast = idx === p.rules.length - 1
return (
<tr key={idx}>
<td className="py-1 pr-3">{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears}`}</td>
<td className="py-1 pr-3">{rule.months} </td>
<td className="py-1 pr-3">{rule.cycleMonths} </td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</Card>
{showForm && (
<MedicalPolicyForm
policy={editPolicy}
onSave={(data) => saveMut.mutate(data)}
onClose={() => { setShowForm(false); setEditPolicy(null) }}
/>
)}
</div>
)
}
function MedicalPolicyForm({ policy, onSave, onClose }: { policy: any; onSave: (data: any) => void; onClose: () => void }) {
const [region, setRegion] = useState(policy?.region || '')
const [legalBasis, setLegalBasis] = useState(policy?.legalBasis || '')
const [isDefault, setIsDefault] = useState(policy?.isDefault || false)
const [rules, setRules] = useState<any[]>(
policy?.rules?.length ? policy.rules : [{ maxYears: 5, months: 3, cycleMonths: 6 }]
)
const addRule = () => setRules([...rules, { maxYears: 10, months: 6, cycleMonths: 12 }])
const updateRule = (idx: number, field: string, value: number) => {
setRules(rules.map((r, i) => i === idx ? { ...r, [field]: value } : r))
}
const removeRule = (idx: number) => {
if (rules.length <= 1) return
setRules(rules.filter((_, i) => i !== idx))
}
const handleSave = () => {
if (!region.trim()) { toast.error('请输入地区名称'); return }
if (!legalBasis.trim()) { toast.error('请输入法律依据'); return }
const sortedRules = [...rules].sort((a, b) => a.maxYears - b.maxYears)
onSave({ region: region.trim(), legalBasis: legalBasis.trim(), rules: sortedRules, isDefault })
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{policy ? '编辑政策' : '新增政策'}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={region} onChange={(e) => setRegion(e.target.value)} placeholder="如:广东" disabled={!!policy?.isDefault} />
</div>
<div>
<Label></Label>
<Input value={legalBasis} onChange={(e) => setLegalBasis(e.target.value)} placeholder="如:《广东省...》" />
</div>
<div>
<Label></Label>
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap"> &lt;</span>
<input type="number" value={rule.maxYears} onChange={(e) => updateRule(idx, 'maxYears', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"> </span>
<input type="number" value={rule.months} onChange={(e) => updateRule(idx, 'months', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"></span>
<input type="number" value={rule.cycleMonths} onChange={(e) => updateRule(idx, 'cycleMonths', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"></span>
{rules.length > 1 && (
<button onClick={() => removeRule(idx)} className="p-1 hover:bg-gray-100 rounded">
<Trash2 className="w-3 h-3 text-red-400" />
</button>
)}
</div>
))}
<button onClick={addRule} className="text-xs text-primary hover:underline">+ </button>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} disabled={!!policy?.isDefault} />
<span></span>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSave}></Button>
</div>
</div>
</div>
</div>
)
}
+23 -15
View File
@@ -11,7 +11,7 @@ import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -19,7 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
export default function SocialInsurance() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'commercial'>('monthly')
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>('monthly')
const [city, setCity] = useState<string>('北京')
const [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
@@ -189,6 +189,10 @@ export default function SocialInsurance() {
setShowNewVersion(false)
toast.success('新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const createHousingVersionMutation = useMutation({
@@ -199,6 +203,10 @@ export default function SocialInsurance() {
setShowNewVersion(false)
toast.success('公积金新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
@@ -347,11 +355,11 @@ export default function SocialInsurance() {
<Calculator 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>
<div className="flex gap-2">
{tab !== 'monthly' && (
{(tab === 'social' || tab === 'housing') && (
<>
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
@@ -367,7 +375,7 @@ export default function SocialInsurance() {
{/* Tab 切换 + 城市选择 */}
<div className="flex items-center gap-4 border-b">
{(['monthly', 'social', 'housing', 'deduction', 'commercial'] as const).map((t) => (
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@@ -375,7 +383,7 @@ export default function SocialInsurance() {
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
>
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'deduction' ? '专项附加扣除' : '商险管理'}
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
</button>
))}
{(tab === 'social' || tab === 'housing') && (
@@ -531,7 +539,7 @@ export default function SocialInsurance() {
)}
{/* 调整预览 */}
{tab !== 'monthly' && showAdjust && adjustData && (
{(tab === 'social' || tab === 'housing') && showAdjust && adjustData && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />{isHousing ? '公积金' : '社保'}
@@ -617,7 +625,7 @@ export default function SocialInsurance() {
)}
{/* 版本历史 */}
{tab !== 'monthly' && showVersions && (
{(tab === 'social' || tab === 'housing') && showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
{!activeVersions || activeVersions.length === 0 ? (
@@ -674,7 +682,7 @@ export default function SocialInsurance() {
)}
{/* 新建版本 */}
{tab !== 'monthly' && showNewVersion && (
{(tab === 'social' || tab === 'housing') && showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
<div className="space-y-3">
@@ -918,7 +926,7 @@ export default function SocialInsurance() {
{tab === 'monthly' && (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<Card>
<div className="flex items-center justify-between mb-3">
@@ -1143,16 +1151,16 @@ export default function SocialInsurance() {
</div>
)}
{/* ========== 员工参保信息 Tab ========== */}
{tab === 'enrollment' && (
<EmployeeEnrollmentTab />
)}
{/* ========== 专项附加扣除 Tab ========== */}
{tab === 'deduction' && (
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
)}
{/* ========== 商险管理 Tab ========== */}
{tab === 'commercial' && (
<CommercialInsuranceTab />
)}
</div>
)
}
+27 -5
View File
@@ -3,7 +3,9 @@
* //
*/
import { useEffect, useState } from 'react'
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
import { usePageSize } from '../hooks/usePageSize'
import { Link } from 'react-router-dom'
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X, Eye } from 'lucide-react'
import { toast } from 'sonner'
import { specialStatusApi, employeeApi } from '../lib/api-services'
import { Input, Select, Label } from '../components/ui/Input'
@@ -102,8 +104,8 @@ function daysUntil(d: string | null): number | null {
export default function SpecialStatus() {
const [list, setList] = useState<SpecialStatus[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
@@ -350,6 +352,9 @@ export default function SpecialStatus() {
</span>
</div>
<div className="flex gap-1">
<Link to={`/roster?employeeId=${item.employee.id}`} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="查看员工详情">
<Eye className="w-3.5 h-3.5" />
</Link>
<button onClick={() => handleOpenEdit(item)} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="编辑">
<Edit2 className="w-3.5 h-3.5" />
</button>
@@ -361,7 +366,7 @@ export default function SpecialStatus() {
{/* 员工信息 */}
<div className="mb-3">
<span className="font-medium text-gray-900">{item.employee.name}</span>
<Link to={`/roster?employeeId=${item.employee.id}`} className="font-medium text-gray-900 hover:text-primary hover:underline">{item.employee.name}</Link>
<span className="text-sm text-gray-500 ml-2">{item.employee.department}</span>
{item.employee.status === 'RESIGNED' && (
<span className="ml-2 text-xs text-gray-400"></span>
@@ -445,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>
@@ -458,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>
+59 -39
View File
@@ -1,12 +1,14 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { templatesApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import mammoth from 'mammoth'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
@@ -23,7 +25,7 @@ const CATEGORY_LABELS: Record<string, string> = {
const VARIABLE_LABELS: Record<string, string> = {
companyName: '公司名称',
employeeName: '员工姓名',
idCard: '身份证号',
idCard: '证件号码',
address: '地址',
startDate: '开始日期',
position: '岗位',
@@ -140,25 +142,21 @@ function SystemTemplates() {
toast.success('已复制到剪贴板')
}
const handleDownloadWord = async () => {
const handleDownloadWord = () => {
if (!selected) return
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/templates/${selected.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selected.name}.doc`
a.click()
URL.revokeObjectURL(url)
toast.success('已下载 Word 文档')
} catch {
toast.error('下载失败')
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const query = new URLSearchParams()
query.set('token', token || '')
for (const [k, v] of Object.entries(variables)) {
if (v) query.append(k, v)
}
const a = document.createElement('a')
a.href = `${baseURL}/templates/${selected.id}/download?${query.toString()}`
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleCopyAsNew = () => {
@@ -302,9 +300,10 @@ function SystemTemplates() {
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const fileInputRef = useRef<HTMLInputElement>(null)
const [category, setCategory] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showEdit, setShowEdit] = useState(false)
const [editItem, setEditItem] = useState<any>(null)
const [form, setForm] = useState({ name: '', category: 'CONTRACT', description: '', content: '' })
@@ -370,25 +369,21 @@ function EnterpriseTemplates() {
}
}
const handleDownloadWord = async () => {
const handleDownloadWord = () => {
if (!selected) return
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/enterprise-templates/${selected.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selected.name}.doc`
a.click()
URL.revokeObjectURL(url)
toast.success('已下载')
} catch {
toast.error('下载失败')
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const query = new URLSearchParams()
query.set('token', token || '')
for (const [k, v] of Object.entries(variables)) {
if (v) query.append(k, v)
}
const a = document.createElement('a')
a.href = `${baseURL}/enterprise-templates/${selected.id}/download?${query.toString()}`
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleEdit = (item: any) => {
@@ -465,7 +460,7 @@ function EnterpriseTemplates() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
{/* 编辑弹窗 */}
@@ -489,6 +484,31 @@ function EnterpriseTemplates() {
</div>
<div>
<Label></Label>
<div className="flex items-center gap-2 mb-1">
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-3.5 h-3.5 mr-1" /> Word
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.convertToHtml({ arrayBuffer })
setForm({ ...form, content: result.value })
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[200px] font-mono"
value={form.content}
+391 -32
View File
@@ -1,12 +1,14 @@
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'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck, PenTool } from 'lucide-react'
import { Stepper } from '../components/ui/Stepper'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import jsPDF from 'jspdf'
import { rosterApi, terminationApi } from '../lib/api-services'
import { rosterApi, terminationApi, esignApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -161,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>>({})
@@ -180,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
@@ -204,8 +232,10 @@ export default function Termination() {
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = useState('')
const draftPageSize = usePageSize()
const [draftPage, setDraftPage] = useState(1)
const [draftPageSize, setDraftPageSize] = useState(20)
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-for-termination'],
@@ -432,6 +462,16 @@ export default function Termination() {
onError: () => toast.error('撤销失败'),
})
// 删除草稿
const deleteDraftMutation = useMutation({
mutationFn: (id: string) => terminationApi.deleteDraft(id),
onSuccess: () => {
toast.success('草稿已删除')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
},
onError: () => toast.error('删除失败'),
})
const { data: evidenceChain } = useQuery({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
@@ -466,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 },
@@ -475,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
// 双倍工资计算(未签合同)
@@ -512,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
}
@@ -555,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({
@@ -570,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)
}
/** 查看详情 */
@@ -662,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
@@ -697,7 +819,7 @@ export default function Termination() {
{view === 'list' && (
<>
<PageGuide>
稿
稿
</PageGuide>
<div className="flex gap-2 flex-wrap items-center">
<Input
@@ -727,8 +849,23 @@ export default function Termination() {
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{(searchTerm || filterStatus || filterDepartment) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment('') }} className="text-xs text-gray-500 hover:text-primary"></button>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="开始日期"
/>
<span className="text-xs text-gray-400"></span>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="结束日期"
/>
{(searchTerm || filterStatus || filterDepartment || filterDateFrom || filterDateTo) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment(''); setFilterDateFrom(''); setFilterDateTo('') }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
<Button variant="secondary" size="sm" onClick={async () => {
try {
@@ -736,6 +873,8 @@ export default function Termination() {
if (searchTerm) params.set('search', searchTerm)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
if (filterDateFrom) params.set('dateFrom', filterDateFrom)
if (filterDateTo) params.set('dateTo', filterDateTo)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/terminations?${params}`, { headers: { Authorization: `Bearer ${token}` } })
@@ -865,6 +1004,20 @@ export default function Termination() {
<Ban className="w-3.5 h-3.5" />
</button>
)}
{(item.status === 'DRAFT' || item.status === 'CANCELLED') && (
<button
onClick={async () => {
if (await confirm({ title: '删除草稿', message: '确定删除此草稿记录?删除后不可恢复。' })) {
deleteDraftMutation.mutate(item.id)
}
}}
className="p-1 text-gray-400 hover:text-danger"
aria-label="删除"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => handleViewDetail(item.id)}
className="p-1 text-gray-500 hover:text-primary"
@@ -886,7 +1039,7 @@ export default function Termination() {
pageSize={draftPageSize}
total={draftsTotal}
onPageChange={setDraftPage}
onPageSizeChange={(s) => { setDraftPageSize(s); setDraftPage(1) }}
onPageSizeChange={() => setDraftPage(1)}
/>
</Card>
</>
@@ -1017,6 +1170,73 @@ export default function Termination() {
</Button>
</div>
)}
{/* 下载离职证明 & 交接清单 */}
{draftDetail.status === 'COMPLETED' && (
<div className="border-t pt-3 flex gap-2 flex-wrap">
<Button
variant="secondary"
onClick={() => {
const reasonLabel = REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title></title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">/</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>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
{draftDetail.handoverItems && draftDetail.handoverItems.length > 0 && (
<Button
variant="secondary"
onClick={() => {
const items = draftDetail.handoverItems.map((item: any) => `<div class="body">${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}</div>`).join('')
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title></title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 18pt; font-weight: bold; margin-bottom: 20pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title"></div>
<div class="body">${draftDetail.employeeName}  ${draftDetail.department || '—'}  ${draftDetail.terminationDate}</div>
${items}
<div class="sign">____________<br/>____________<br/>____________</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
)}
</div>
)}
</div>
</Card>
)}
@@ -1164,7 +1384,26 @@ export default function Termination() {
})()}
<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 && (
@@ -1317,6 +1556,57 @@ export default function Termination() {
)}
</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">
@@ -1385,8 +1675,55 @@ export default function Termination() {
{/* 合计 */}
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
<span className="font-medium"></span>
<span className="text-lg font-bold text-gray-700">¥{fmt(costResult.grandTotal)}</span>
</div>
{compAdjustments.length > 0 && (
<div className="flex items-center justify-between mt-1">
<span className="font-medium text-primary"></span>
<span className="text-lg font-bold text-danger">¥{fmt(adjustedTotal)}</span>
</div>
)}
</div>
{/* 违法解除风险提醒:实际补偿金低于法定2N时需确认 */}
{reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal && (
<div className="border border-danger rounded-md p-3 space-y-2 bg-red-50">
<div className="text-xs font-medium text-danger flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4" />
</div>
<p className="text-xs text-danger">
287 ¥{fmt(adjustedTotal)} ¥{fmt(costResult.grandTotal)} ¥{fmt(costResult.grandTotal - adjustedTotal)}
</p>
<p className="text-xs text-danger"></p>
<label className="flex items-center gap-2 text-xs text-danger cursor-pointer">
<input
type="checkbox"
checked={illegalRiskAcknowledged}
onChange={(e) => setIllegalRiskAcknowledged(e.target.checked)}
/>
<span></span>
</label>
</div>
)}
{/* 剩余年假折算 */}
<div className="border rounded-md p-3 space-y-2 bg-green-50/30">
<div className="text-xs font-medium flex items-center gap-1.5">
<Calculator className="w-3.5 h-3.5" />
</div>
<div className="text-xs text-gray-500">300%5</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<Label></Label>
<Input type="number" min="0" step="0.5" value={annualLeaveDays || ''} onChange={(e) => setAnnualLeaveDays(Number(e.target.value) || 0)} placeholder="0" />
</div>
{annualLeaveDays > 0 && costResult && (
<div className="text-xs text-gray-600">
<div>¥{fmt((costResult.cappedWage || 0) / 21.75)}/</div>
<div className="font-medium text-danger">¥{fmt(annualLeavePay)}</div>
</div>
)}
</div>
</div>
@@ -1492,6 +1829,28 @@ export default function Termination() {
placeholder="备注说明(选填)"
className="text-xs"
/>
{item.key === 'docs_signed' && employeeId && (
<Button
size="sm"
variant="secondary"
onClick={async () => {
try {
await esignApi.create({
employeeId,
documentTitle: '离职协议',
remark: '离职流程中发起',
scene: 'RESIGNATION',
})
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('离职协议电子签署已发起')
} catch {
toast.error('发起签署失败')
}
}}
>
<PenTool className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
))}
<div className="flex items-center gap-2 text-xs text-gray-500">
@@ -1751,11 +2110,11 @@ export default function Termination() {
</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" />
+460 -68
View File
@@ -1,12 +1,16 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye,
Repeat, Pause, FileText, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
} from 'lucide-react'
import { workProcessApi, templatesApi } from '../lib/api-services'
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -19,7 +23,6 @@ const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
TERMINATE: XCircle, RESCIND: UserX, LEAVING_CERT: FileMinus,
FLEXIBLE: Briefcase,
}
@@ -33,9 +36,6 @@ const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
RENEW: { label: '合同续签', description: '到期合同续签' },
SUSPEND: { label: '合同中止', description: '中止履行合同' },
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
TERMINATE: { label: '合同终止', description: '合同到期终止' },
RESCIND: { label: '合同解除', description: '协商或单方解除合同' },
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明' },
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
}
@@ -49,31 +49,31 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
}
// 各流程类型的表单字段配置
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template'; options?: string[] }[]> = {
// 各流程类型的表单字段配置required 标记必填项)
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template' | 'employee-select' | 'contract-select'; options?: string[]; required?: boolean }[]> = {
HIRE: [
{ key: 'name', label: '员工姓名', type: 'text' },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'name', label: '员工姓名', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
{ key: 'monthlySalary', label: '月薪', type: 'number' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'phone', 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' },
],
ONBOARD: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
],
CUSTOM_CONTRACT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date', required: true },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
],
INFO_SUBMIT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'address', label: '地址', type: 'text' },
@@ -81,61 +81,65 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
],
CONFIRM: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'confirmDate', label: '转正日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'confirmDate', label: '转正日期', type: 'date', required: true },
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
],
CHANGE: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'newEndDate', label: '新到期日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'newEndDate', label: '新到期日期', type: 'date', required: true },
],
RENEW: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'oldContractId', label: '原合同', type: 'contract-select' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date', required: true },
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
{ key: 'newSalary', label: '新薪资', type: 'number' },
],
SUSPEND: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'suspendDate', label: '中止日期', type: 'date' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
{ key: 'suspendDate', label: '中止日期', type: 'date', required: true },
],
INCOME_CERT: [
{ 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' },
],
TERMINATE: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'terminateDate', label: '终止日期', type: 'date' },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'terminateDate', label: '终止日期', type: 'date', required: true },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
RESCIND: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'rescindDate', label: '解除日期', type: 'date' },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'rescindDate', label: '解除日期', type: 'date', required: true },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
LEAVING_CERT: [
{ 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' },
{ key: 'leaveDate', label: '离职日期', type: 'date', required: true },
],
FLEXIBLE: [
{ key: 'name', label: '姓名', type: 'text' },
{ key: 'name', label: '姓名', type: 'text', required: true },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'idCardNumber', label: '证件号码', type: 'text', required: true },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date' },
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date', required: true },
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
{ key: 'payMethod', label: '计酬方式', type: 'text' },
],
@@ -147,17 +151,67 @@ 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()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>('')
const [formData, setFormData] = useState<Record<string, any>>({})
const [selectedType, setSelectedType] = useState<string>(() => {
try { return localStorage.getItem('workprocess-draft-type') || '' } catch { return '' }
})
const [formData, setFormData] = useState<Record<string, any>>(() => {
try {
const saved = localStorage.getItem('workprocess-draft-data')
return saved ? JSON.parse(saved) : {}
} catch { return {} }
})
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [previewContent, setPreviewContent] = useState<string | null>(null)
const [showBatch, setShowBatch] = useState(false)
const [batchType, setBatchType] = useState<string>('INCOME_CERT')
const [batchEmployees, setBatchEmployees] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
if (selectedType && Object.keys(formData).length > 0) {
localStorage.setItem('workprocess-draft-type', selectedType)
localStorage.setItem('workprocess-draft-data', JSON.stringify(formData))
} else {
localStorage.removeItem('workprocess-draft-type')
localStorage.removeItem('workprocess-draft-data')
}
} catch {}
}, [selectedType, formData])
const isDirty = selectedType && Object.keys(formData).length > 0
useUnsavedChanges(!!isDirty)
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
@@ -174,13 +228,15 @@ export default function WorkProcess() {
return await workProcessApi.create(data)
},
onSuccess: () => {
toast.success('已创建草稿')
toast.success('已创建草稿,可在列表中查看详情并提交', {
action: { label: '去提交', onClick: () => {} },
})
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCreate(false)
setFormData({})
setSelectedType('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
onError: (err: any) => toastError(err, '创建失败'),
})
const submitMutation = useMutation({
@@ -188,11 +244,14 @@ export default function WorkProcess() {
return await workProcessApi.submit(id)
},
onSuccess: () => {
toast.success('已提交并执行')
toast.success('已提交并执行', {
action: { label: '查看文书', onClick: () => navigate('/evidence') },
})
toast.info('文书已生成,可在「证据管理」中查看和下载', { duration: 5000 })
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
onError: (err: any) => toastError(err, '提交失败'),
})
const cancelMutation = useMutation({
@@ -204,7 +263,7 @@ export default function WorkProcess() {
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '撤销失败'),
onError: (err: any) => toastError(err, '撤销失败'),
})
const deleteMutation = useMutation({
@@ -215,7 +274,7 @@ export default function WorkProcess() {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
onError: (err: any) => toastError(err, '删除失败'),
})
const previewMutation = useMutation({
@@ -225,7 +284,7 @@ export default function WorkProcess() {
onSuccess: (data) => {
setPreviewContent(data.content)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '预览失败'),
onError: (err: any) => toastError(err, '预览失败'),
})
const handleCreate = () => {
@@ -233,37 +292,174 @@ export default function WorkProcess() {
toast.error('请选择流程类型')
return
}
// 校验必填项
const requiredFields = (FORM_FIELDS[selectedType] || []).filter(f => f.required)
const missingFields = requiredFields.filter(f => !formData[f.key] || String(formData[f.key]).trim() === '')
if (missingFields.length > 0) {
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,
employeeId: formData.employeeId || undefined,
formData,
status: 'DRAFT',
})
}
const handleCreateAndSubmit = () => {
if (!selectedType) {
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' },
{
onSuccess: (data: any) => {
const newId = data?.id
if (newId) {
submitMutation.mutate(newId)
} else {
toast.success('草稿已创建,请手动提交')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
}
setShowCreate(false)
setFormData({})
setSelectedType('')
},
}
)
}
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
// 选择员工后自动填充相关字段
if (key === 'employeeId' && value) {
employeeApi.detail(value).then((emp: any) => {
setFormData(prev => ({
...prev,
employeeName: emp.name || prev.employeeName,
idCardNumber: emp.idCardNumber || prev.idCardNumber,
position: emp.position || prev.position,
monthlyIncome: emp.monthlySalary ? String(emp.monthlySalary) : prev.monthlyIncome,
hireDate: emp.hireDate ? emp.hireDate.slice(0, 10) : prev.hireDate,
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(() => {})
}
}
const handleBatchSubmit = () => {
if (batchEmployees.length === 0) {
toast.error('请至少选择一名员工')
return
}
let success = 0
let failed = 0
Promise.all(
batchEmployees.map(async (empId) => {
try {
const emp = allEmployees.find((e: any) => e.id === empId)
if (!emp) return
const data: any = {
type: batchType,
title: PROCESS_TYPES[batchType].label,
employeeId: empId,
formData: {
employeeName: emp.name,
idCardNumber: emp.idCardNumber || '',
position: emp.position || '',
},
status: 'DRAFT',
}
const res: any = await workProcessApi.create(data)
if (res?.id) {
await workProcessApi.submit(res.id)
success++
}
} catch {
failed++
}
})
).then(() => {
toast.success(`批量开具完成:成功 ${success}${failed > 0 ? ',失败 ' + failed + ' 个' : ''}`)
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowBatch(false)
setBatchEmployees([])
setBatchSearch('')
})
}
const { data: allEmployees = [] } = useQuery<any[]>({
queryKey: ['employee-list-batch'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filteredEmployees = allEmployees.filter((e: any) => {
if (!batchSearch) return true
return e.name.includes(batchSearch) || (e.department || '').includes(batchSearch)
})
const items = listData?.items || []
const total = listData?.total || 0
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 发起办理 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-amber-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
稿{PROCESS_TYPES[selectedType]?.label}
<button className="text-primary hover:underline" onClick={() => { setSelectedType(''); setFormData({}) }}></button>
</span>
)}
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
<Users className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 13类流程卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
@@ -329,10 +525,18 @@ export default function WorkProcess() {
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{item.employee ? `${item.employee.name} · ${item.employee.department}` : '未关联员工'}
{item.employee ? `${item.employee.name} · ${item.employee.department}` : (item.formData?.employeeName || item.formData?.name || '未关联员工')}
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{(item.status === 'COMPLETED' || item.status === 'EXECUTING') && item.documents && item.documents.length > 0 && (
<button
className="text-xs text-primary hover:underline shrink-0 flex items-center gap-0.5"
onClick={(e) => { e.stopPropagation(); setDetailId(item.id) }}
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<ChevronRight className="w-4 h-4 text-gray-300" />
</div>
)
@@ -344,12 +548,12 @@ export default function WorkProcess() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</Card>
{/* 创建/编辑弹窗 */}
<Modal open={showCreate} onClose={() => { setShowCreate(false); setFormData({}); setSelectedType('') }} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
{!selectedType ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
@@ -374,7 +578,7 @@ export default function WorkProcess() {
<div className="text-xs text-gray-500 mb-2">{PROCESS_TYPES[selectedType]?.description}</div>
{(FORM_FIELDS[selectedType] || []).map(field => (
<div key={field.key}>
<Label>{field.label}</Label>
<Label>{field.label}{field.required && <span className="text-danger ml-0.5">*</span>}</Label>
{field.type === 'select' ? (
<Select value={formData[field.key] || ''} onChange={(e) => handleFieldChange(field.key, e.target.value)}>
<option value=""></option>
@@ -388,6 +592,19 @@ export default function WorkProcess() {
/>
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'employee-select' ? (
<EmployeeSelect value={formData[field.key] || ''} onChange={(emp) => {
handleFieldChange(field.key, emp.id)
if (emp.name) handleFieldChange('employeeName', emp.name)
if (emp.idCardNumber) handleFieldChange('idCardNumber', emp.idCardNumber)
if (emp.position) handleFieldChange('position', emp.position)
if (emp.monthlySalary) handleFieldChange('monthlyIncome', String(emp.monthlySalary))
if (emp.hireDate) handleFieldChange('hireDate', emp.hireDate?.slice(0, 10))
if (emp.department) handleFieldChange('department', emp.department)
if (emp.phone) handleFieldChange('phone', emp.phone)
}} />
) : field.type === 'contract-select' ? (
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
@@ -402,7 +619,11 @@ export default function WorkProcess() {
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
稿
</Button>
<Button variant="secondary" onClick={() => { setSelectedType(''); setFormData({}) }}>
<Button onClick={handleCreateAndSubmit} disabled={createMutation.isPending || submitMutation.isPending}>
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button variant="secondary" onClick={() => setSelectedType('')}>
</Button>
</div>
@@ -422,6 +643,61 @@ export default function WorkProcess() {
loading={submitMutation.isPending || cancelMutation.isPending}
/>
</Modal>
{/* 批量开具证明弹窗 */}
<Modal open={showBatch} onClose={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }} title="批量开具证明" size="lg">
<div className="space-y-4">
<div>
<Label></Label>
<Select value={batchType} onChange={(e) => setBatchType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div>
<Label> {batchEmployees.length} </Label>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
placeholder="搜索姓名或部门..."
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
{filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer border-b last:border-0"
>
<input
type="checkbox"
checked={batchEmployees.includes(emp.id)}
onChange={() => {
setBatchEmployees(prev =>
prev.includes(emp.id) ? prev.filter(id => id !== emp.id) : [...prev, emp.id]
)
}}
/>
<span className="text-sm flex-1">{emp.name}</span>
<span className="text-xs text-gray-400">{emp.department || '—'}</span>
</label>
))}
{filteredEmployees.length === 0 && (
<div className="text-center py-4 text-xs text-gray-400"></div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="secondary" size="sm" onClick={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }}></Button>
<Button size="sm" onClick={handleBatchSubmit} disabled={batchEmployees.length === 0}>
<Send className="w-4 h-4 mr-1" />
{batchEmployees.length}
</Button>
</div>
</div>
</Modal>
</div>
)
}
@@ -458,7 +734,7 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : '未关联员工'}
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : (data.formData?.employeeName || data.formData?.name || '未关联员工')}
</div>
</div>
</div>
@@ -489,11 +765,38 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
{data.documents && data.documents.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="space-y-1">
<div className="space-y-2">
{data.documents.map((doc: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<FileText className="w-3 h-3 text-gray-400" />
<span>{doc.name}</span>
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded-md p-2">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 truncate">{doc.name}</span>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => onPreview(data.id)}
>
<Eye className="w-3.5 h-3.5 inline mr-0.5" />
</button>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => {
const content = previewContent || ''
if (!content) {
onPreview(data.id)
return
}
const blob = new Blob(['\ufeff' + content], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = doc.name.endsWith('.doc') ? doc.name : `${doc.name}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-3.5 h-3.5 inline mr-0.5" />
</button>
</div>
))}
</div>
@@ -549,3 +852,92 @@ function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange
</div>
)
}
function EmployeeSelect({ value, onChange }: { value: string; onChange: (employee: any) => void }) {
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const { data: employees = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-list-for-select'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filtered = employees.filter((e: any) => {
if (!search) return true
return e.name.includes(search) || (e.department || '').includes(search) || (e.phone || '').includes(search)
})
const selected = employees.find((e: any) => e.id === value)
return (
<div className="relative">
<div
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm cursor-pointer flex items-center justify-between"
onClick={() => setOpen(!open)}
>
{selected ? (
<span>{selected.name} · {selected.department || '未分配部门'}</span>
) : (
<span className="text-gray-400">{isLoading ? '加载中...' : '点击选择员工'}</span>
)}
<Search className="w-3.5 h-3.5 text-gray-400" />
</div>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white rounded-md border border-gray-200 shadow-lg max-h-[240px] overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
type="text"
autoFocus
placeholder="搜索姓名/部门/手机号"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="overflow-y-auto max-h-[180px]">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : (
filtered.map((e: any) => (
<div
key={e.id}
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
onClick={() => {
onChange(e)
setOpen(false)
setSearch('')
}}
>
<span>{e.name}</span>
<span className="text-xs text-gray-400">{e.department || ''}</span>
</div>
))
)}
</div>
</div>
)}
</div>
)
}
function ContractSelect({ value, onChange, employeeId }: { value: string; onChange: (v: string) => void; employeeId: string }) {
const { data: contracts = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-contracts', employeeId],
queryFn: async () => {
if (!employeeId) return []
const res = await employeeApi.detail(employeeId)
return res?.contracts || []
},
enabled: !!employeeId,
})
const activeContracts = contracts.filter((c: any) => c.status === 'ACTIVE' || c.status === 'SUSPENDED')
return (
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={!employeeId}>
<option value="">{!employeeId ? '请先选择员工' : isLoading ? '加载中...' : activeContracts.length === 0 ? '无可用合同' : '请选择合同'}</option>
{activeContracts.map((c: any) => (
<option key={c.id} value={c.id}>
{c.contractType === 'UNFIXED' ? '无固定期限' : `${c.startDate?.slice(0, 10)} ~ ${c.endDate?.slice(0, 10)}`}{c.status === 'SUSPENDED' ? '(已中止)' : ''}
</option>
))}
</Select>
)
}
+128 -26
View File
@@ -4,15 +4,17 @@
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import {
ShieldAlert, AlertTriangle, Clock, Users, FileText,
TrendingDown, Calendar, ChevronRight, Filter,
TrendingDown, Calendar, ChevronRight, Filter, CheckSquare,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
import PageGuide from '../../components/ui/PageGuide'
import QueryError from '../../components/ui/QueryError'
import Pagination from '../../components/ui/Pagination'
import { usePageSize } from '../../hooks/usePageSize'
import { dashboardApi } from '../../lib/api-services'
/** 风险等级配置 */
@@ -23,8 +25,8 @@ const RISK_LEVELS: Record<string, { label: string; color: string; bg: string }>
}
/** 风险类型配置 */
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string }> = {
CONTRACT: { label: '合同风险', icon: FileText, link: '/roster' },
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string; extraParams?: string }> = {
CONTRACT: { label: '合同风险', icon: FileText, link: '/roster', extraParams: 'contractStatus=expired' },
SALARY: { label: '薪酬风险', icon: TrendingDown, link: '/money' },
TERMINATION: { label: '解聘风险', icon: Users, link: '/termination' },
MONTHLY: { label: '月度任务', icon: Calendar, link: '/money' },
@@ -34,7 +36,11 @@ const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatch, setShowBatch] = useState(false)
/** 获取风险列表 */
const { data: risks = [], isLoading, isError, error, refetch } = useQuery<any[]>({
@@ -70,6 +76,35 @@ export default function RiskCenter() {
})
}, [risks, filterLevel, filterType])
/** 构建带筛选参数的跳转链接 */
const buildLink = (r: any) => {
const typeCfg = RISK_TYPES[r.type] || { link: '/', extraParams: '' }
const params: string[] = []
if (r.employee) params.push(`employee=${encodeURIComponent(r.employee.name)}`)
if (typeCfg.extraParams) params.push(typeCfg.extraParams)
return params.length > 0 ? `${typeCfg.link}?${params.join('&')}` : typeCfg.link
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === pagedRisks.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(pagedRisks.map((r: any, i: number) => `${r.type}-${i}`)))
}
}
const total = filteredRisks.length
const pagedRisks = filteredRisks.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-4">
<PageGuide>
@@ -124,7 +159,7 @@ export default function RiskCenter() {
return (
<Link
key={key}
to={cfg.link}
to={cfg.extraParams ? `${cfg.link}?${cfg.extraParams}` : cfg.link}
className="flex items-center gap-2 p-2.5 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/5 transition-colors"
>
<Icon className="w-4 h-4 text-gray-400" />
@@ -161,6 +196,26 @@ export default function RiskCenter() {
>{cfg.label}</button>
))}
</div>
<div className="flex gap-1 ml-2">
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterType === 'ALL' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterType('ALL')}
></button>
{Object.entries(RISK_TYPES).map(([key, cfg]) => (
<button
key={key}
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterType === key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterType(key)}
>{cfg.label}</button>
))}
</div>
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ml-auto flex items-center gap-1 ${showBatch ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => { setShowBatch(!showBatch); setSelectedIds(new Set()) }}
>
<CheckSquare className="w-3.5 h-3.5" />
{showBatch ? '退出批量' : '批量处理'}
</button>
</div>
{/* 风险列表 */}
@@ -174,38 +229,85 @@ export default function RiskCenter() {
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
</div>
) : (
<>
{showBatch && pagedRisks.length > 0 && (
<div className="flex items-center gap-2 mb-2 px-3 py-2 bg-gray-50 rounded-md">
<button onClick={toggleSelectAll} className="text-xs text-primary hover:underline">
{selectedIds.size === pagedRisks.length ? '取消全选' : '全选'}
</button>
<span className="text-xs text-gray-500"> {selectedIds.size} </span>
{selectedIds.size > 0 && (
<>
<Link to={`/contracts?batch=${encodeURIComponent(Array.from(selectedIds).join(','))}`} className="text-xs text-primary hover:underline ml-2">
</Link>
<Link to={`/termination?batch=${encodeURIComponent(Array.from(selectedIds).join(','))}`} className="text-xs text-primary hover:underline">
</Link>
</>
)}
</div>
)}
<div className="space-y-2">
{filteredRisks.map((r: any, i: number) => {
{pagedRisks.map((r: any, i: number) => {
const levelCfg = RISK_LEVELS[r.level] || RISK_LEVELS.LOW
const typeCfg = RISK_TYPES[r.type] || { label: r.type, icon: AlertTriangle, link: '/' }
const Icon = typeCfg.icon
const link = buildLink(r)
const itemId = `${r.type}-${i}`
return (
<Link
<div
key={i}
to={typeCfg.link}
className={`flex items-start gap-3 p-3 rounded-lg border ${levelCfg.bg} hover:shadow-sm transition-shadow`}
>
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
<div className="text-sm text-gray-700 mt-0.5">{r.title}</div>
{r.description && (
<div className="text-xs text-gray-500 mt-0.5">{r.description}</div>
)}
{r.employee && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employee.name} · {r.employee.department || ''}
{showBatch && (
<input
type="checkbox"
checked={selectedIds.has(itemId)}
onChange={() => toggleSelect(itemId)}
className="mt-1 shrink-0"
/>
)}
<Link to={link} className="flex items-start gap-3 flex-1 min-w-0">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
<div className="text-sm text-gray-700 mt-0.5">{r.title}</div>
{r.description && (
<div className="text-xs text-gray-500 mt-0.5">{r.description}</div>
)}
{r.employee && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employee.name} · {r.employee.department || ''}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
{r.type === 'CONTRACT_EXPIRY' && (
<Link to={`/contracts?employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
{r.type === 'PROBATION_END' && (
<Link to={`/work-process?type=REGULAR&employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
{r.type === 'TERMINATION_RISK' && (
<Link to={`/termination?employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
</div>
)
})}
</div>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
</Card>
</div>
+2 -1
View File
@@ -4,7 +4,7 @@
*/
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { AlertCircle, Layers, FileText, Heart, ArrowRight, ListTodo } from 'lucide-react'
import { AlertCircle, Layers, FileText, Heart, ArrowRight, ListTodo, Wallet } from 'lucide-react'
import { dashboardApi } from '../../lib/api-services'
import { InlineAlert } from '../../components/ui/InlineAlert'
@@ -27,6 +27,7 @@ interface ActionGroup {
const categoryIcons: Record<string, typeof AlertCircle> = {
'待办事项': AlertCircle,
'发薪批次': Layers,
'发薪提醒': Wallet,
'合同到期': FileText,
'特殊状态': Heart,
}
+27 -11
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
@@ -100,6 +101,8 @@ export function BatchManager() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthFrom, setMonthFrom] = useState('')
const [monthTo, setMonthTo] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [filterType, setFilterType] = useState<string>('')
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
@@ -108,8 +111,8 @@ export function BatchManager() {
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
const [sourceBatchId, setSourceBatchId] = useState<string>('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<string[]>([])
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const { data: checkResult } = useQuery<any>({
queryKey: ['batch-check', month],
@@ -119,12 +122,14 @@ export function BatchManager() {
})
const { data: batches, isLoading } = useQuery<any[]>({
queryKey: ['batches', month, monthFrom, monthTo, filterStatus, filterType],
queryKey: ['batches', month, monthFrom, monthTo, dateFrom, dateTo, filterStatus, filterType],
queryFn: async () => {
const params: any = {}
if (month && !monthFrom && !monthTo) params.month = month
if (month && !monthFrom && !monthTo && !dateFrom && !dateTo) params.month = month
if (monthFrom) params.monthFrom = monthFrom
if (monthTo) params.monthTo = monthTo
if (dateFrom) params.dateFrom = dateFrom
if (dateTo) params.dateTo = dateTo
if (filterStatus) params.status = filterStatus
if (filterType) params.type = filterType
return await payrollApi.batches(params)
@@ -214,9 +219,15 @@ export function BatchManager() {
<span className="text-xs text-gray-500"></span>
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="!w-36" />
</div>
{!monthFrom && !monthTo && (
{!monthFrom && !monthTo && !dateFrom && !dateTo && (
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" />
)}
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-gray-500"></span>
<Input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setMonth('') }} className="!w-36" placeholder="起始" />
<span className="text-xs text-gray-500">~</span>
<Input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setMonth('') }} className="!w-36" placeholder="截止" />
</div>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="!w-28 shrink-0">
<option value=""></option>
<option value="DRAFT">稿</option>
@@ -229,8 +240,8 @@ export function BatchManager() {
<option value="BONUS"></option>
<option value="SEVERANCE"></option>
</Select>
{(monthFrom || monthTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
{(monthFrom || monthTo || dateFrom || dateTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setDateFrom(''); setDateTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
</button>
)}
@@ -313,7 +324,6 @@ export function BatchManager() {
<EmptyState title="本月暂无发薪批次" description="点击「创建发薪批次」开始" />
) : (
<div className="space-y-3">
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -327,6 +337,7 @@ export function BatchManager() {
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
@@ -376,6 +387,7 @@ export function BatchManager() {
<td className="py-2.5 px-3 text-right text-sm text-cyan-600">¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))}</td>
<td className="py-2.5 px-3 text-right text-sm text-danger">¥{fmt(batch.totalTax)}</td>
<td className="py-2.5 px-3 text-right text-sm font-bold text-safe">¥{fmt(batch.totalNetPay)}</td>
<td className="py-2.5 px-3 text-xs text-gray-500">{batch.createdAt ? new Date(batch.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-2.5 px-3">
{batch.status === 'ARCHIVED' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
@@ -435,6 +447,7 @@ export function BatchManager() {
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</div>
)}
</div>
@@ -446,8 +459,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const confirm = useConfirm()
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
const [editValue, setEditValue] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showAddEmployee, setShowAddEmployee] = useState(false)
const payrollFileRef = useRef<HTMLInputElement>(null)
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
@@ -469,6 +482,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '保存失败,请重试')
},
})
const removeEmployeeMutation = useMutation({
@@ -1013,9 +1029,6 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
{/* 人员表格 */}
<Card>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -1082,6 +1095,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</tbody>
</table>
</div>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
)}
</Card>
{/* 定时发送弹窗 */}
+81 -7
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X } from 'lucide-react'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X, Plus } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -18,6 +18,8 @@ export function OvertimeCalculator() {
const [previewData, setPreviewData] = useState<any[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
const [showAddForm, setShowAddForm] = useState(false)
const [addForm, setAddForm] = useState({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
// 加班费规则配置
const { data: config, isLoading: configLoading } = useQuery<any>({
@@ -60,6 +62,17 @@ export function OvertimeCalculator() {
},
})
// 从考勤记录同步加班工时
const syncFromAttendanceMutation = useMutation({
mutationFn: (m: string) => payrollApi.syncOvertimeFromAttendance(m),
onSuccess: (data: any) => {
toast.success(`已从考勤同步 ${data.synced || 0} 位员工的加班工时`)
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setStep(3)
},
onError: () => toast.error('同步失败'),
})
// 更新单条加班记录
const updateOvertimeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
@@ -87,6 +100,18 @@ export function OvertimeCalculator() {
}
}
// 手动添加加班记录
const addOvertimeMutation = useMutation({
mutationFn: (data: any) => payrollApi.saveOvertime(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setShowAddForm(false)
setAddForm({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
toast.success('加班记录已添加')
},
onError: () => toast.error('添加失败'),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -265,13 +290,18 @@ export function OvertimeCalculator() {
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
</div>
<div className="border-t pt-3">
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择CSV文件'}
</Button>
<div className="flex gap-2 items-center">
<input ref={fileInputRef} type="file" accept=".csv,.xlsx,.xls" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择文件导入'}
</Button>
<Button variant="secondary" onClick={() => syncFromAttendanceMutation.mutate(month)} disabled={syncFromAttendanceMutation.isPending}>
{syncFromAttendanceMutation.isPending ? '同步中...' : '从考勤同步'}
</Button>
</div>
<div className="text-xs text-gray-500 mt-2">
CSV格式,(h),(h),(h),()
,(h),(h),(h),()
</div>
</div>
@@ -329,8 +359,52 @@ export function OvertimeCalculator() {
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-32" />
<Button variant="secondary" size="sm" onClick={() => refetch()}></Button>
<Button size="sm" onClick={() => setShowAddForm(!showAddForm)}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
</div>
</div>
{showAddForm && (
<div className="border rounded-lg p-3 mb-3 bg-gray-50 space-y-3">
<div className="grid md:grid-cols-4 gap-3">
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
value={addForm.employeeId}
onChange={(e) => setAddForm({ ...addForm, employeeId: e.target.value })}
>
<option value=""></option>
{employees?.items?.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekdayHours} onChange={(e) => setAddForm({ ...addForm, weekdayHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekendHours} onChange={(e) => setAddForm({ ...addForm, weekendHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.holidayHours} onChange={(e) => setAddForm({ ...addForm, holidayHours: Number(e.target.value) })} />
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => {
if (!addForm.employeeId) return toast.error('请选择员工')
const emp = employees?.items?.find((e: any) => e.id === addForm.employeeId)
let monthlyWage = 0
try { monthlyWage = Number((emp as any)?.monthlySalary) || 0 } catch {}
addOvertimeMutation.mutate({ ...addForm, month, monthlyWage: monthlyWage || 1 })
}} disabled={addOvertimeMutation.isPending || !addForm.employeeId}>
{addOvertimeMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowAddForm(false)}></Button>
</div>
</div>
)}
{!overtimeRecords || overtimeRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
+91 -14
View File
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Calculator, Check, Layers, Settings as X } from 'lucide-react'
import { Calculator, Check, Layers, Settings as X, Download, Eye } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -13,12 +14,12 @@ import Pagination from '../../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export function PayslipManager() {
export function PayslipManager({ filterEmployeeId }: { filterEmployeeId?: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showTaxPreview, setShowTaxPreview] = useState(false)
const [previewData, setPreviewData] = useState({
baseSalary: 0,
@@ -30,9 +31,11 @@ export function PayslipManager() {
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
queryKey: ['payslips', month, filterEmployeeId],
queryFn: async () => {
return await payrollApi.payslips({ month })
const all = await payrollApi.payslips({ month })
if (!filterEmployeeId) return all
return all.filter((p: any) => p.employeeId === filterEmployeeId)
},
})
@@ -67,20 +70,86 @@ export function PayslipManager() {
return (
<div className="space-y-3">
<PageGuide>
PDF
PDF
</PageGuide>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
{payslips && payslips.length > 0 && (
<div className="flex gap-2 text-xs">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning"> {unconfirmedCount}</span>
<div className="flex items-center gap-2 text-xs flex-nowrap">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 whitespace-nowrap"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe whitespace-nowrap"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning whitespace-nowrap"> {unconfirmedCount}</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['员工', '部门', '基本工资', '加班费', '津贴', '奖金', '扣款', '应发合计', '个税', '实发', '确认状态']
const rows = payslips.map((p: any) => [
p.employee?.name || '',
p.employee?.department || '',
p.baseSalary || 0,
p.overtimePay || 0,
p.allowance || 0,
p.bonus || 0,
p.deduction || 0,
p.totalPay || 0,
p.tax || 0,
p.netPay || 0,
p.confirmedAt ? '已确认' : '未确认',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资表-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资表')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['序号', '收款人姓名', '收款账号', '开户行', '金额', '用途', '备注']
const rows = payslips.map((p: any, i: number) => [
i + 1,
p.employee?.name || '',
p.employee?.bankAccount || '',
p.employee?.bankName || '',
(p.netPay || 0).toFixed(2),
`${month}月工资`,
'',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资流水-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资流水')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={() => setShowTaxPreview(true)}
>
@@ -107,7 +176,6 @@ export function PayslipManager() {
<Card><div className="text-center py-8 text-gray-500"></div></Card>
) : (
<Card>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -144,8 +212,16 @@ export function PayslipManager() {
<span className="inline-flex items-center gap-1 text-safe text-xs">
<Check className="w-3 h-3" />
</span>
) : p.viewedAt ? (
<span className="inline-flex items-center gap-1 text-blue-600 text-xs" title={`查看于 ${new Date(p.viewedAt).toLocaleString('zh-CN')}`}>
<Eye className="w-3 h-3" />
</span>
) : p.publishedAt ? (
<span className="inline-flex items-center gap-1 text-gray-400 text-xs">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-warning text-xs"></span>
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="py-2 px-2">
@@ -161,6 +237,7 @@ export function PayslipManager() {
</tbody>
</table>
</div>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</Card>
)}
+3 -2
View File
@@ -2,6 +2,7 @@
*
*/
import { useEffect, useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { Search, Trash2, Edit2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
@@ -11,7 +12,7 @@ import Button from '../../components/ui/Button'
interface Org {
id: string; name: string; plan: string; maxEmployees: number
city: string | null; contactName: string | null; contactPhone: string | null
payrollFrequency: number; retirementReminderEnabled: boolean
payrollDays: number[]; retirementReminderEnabled: boolean
createdAt: string; updatedAt: string
employeeCount: number; userCount: number; contractCount: number; payslipCount: number
}
@@ -22,8 +23,8 @@ const PLAN_COLORS: Record<string, string> = { FREE: 'bg-gray-100 text-gray-700',
export default function PlatformOrgs() {
const [orgs, setOrgs] = useState<Org[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [planFilter, setPlanFilter] = useState('')
const [loading, setLoading] = useState(true)
@@ -2,6 +2,7 @@
* /
*/
import { useEffect, useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { Search, Ban, CheckCircle } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
@@ -25,8 +26,8 @@ const ROLE_COLORS: Record<string, string> = {
export default function PlatformUsers() {
const [users, setUsers] = useState<UserItem[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [loading, setLoading] = useState(true)
+15 -1
View File
@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
AlertCircle, ChevronRight,
AlertCircle, ChevronRight, UserX, PenTool,
} from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -19,6 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
const QUICK_ACTIONS = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
{ path: '/portal/esign', label: '电子签署', icon: PenTool, color: 'bg-violet-50 text-violet-600' },
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
@@ -219,6 +220,19 @@ export default function EmployeeHome() {
</div>
</Card>
)}
{/* 辞职申请入口 */}
<Card>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserX className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium"></span>
</div>
<Link to="/portal/resignation" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
</Card>
</div>
)
}
+204
View File
@@ -0,0 +1,204 @@
/**
*
* /
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { PenTool, FileText, CheckCircle2, Clock, XCircle, ChevronLeft } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState'
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待签署', color: 'bg-amber-50 text-amber-700', icon: <Clock className="w-3 h-3" /> },
COMPLETED: { label: '已签署', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-400', icon: <XCircle className="w-3 h-3" /> },
EXPIRED: { label: '已过期', color: 'bg-red-50 text-red-600', icon: <XCircle className="w-3 h-3" /> },
}
const SCENE_LABELS: Record<string, { label: string; color: string }> = {
CONTRACT: { label: '劳动合同', color: 'bg-blue-50 text-blue-600 border border-blue-200' },
RESIGNATION: { label: '离职协议', color: 'bg-orange-50 text-orange-600 border border-orange-200' },
POLICY: { label: '规章制度', color: 'bg-amber-50 text-amber-600 border border-amber-200' },
PAYSLIP: { label: '工资条', color: 'bg-emerald-50 text-emerald-600 border border-emerald-200' },
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
}
export default function MyEsign() {
const queryClient = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
const { data: list = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-esign'],
queryFn: () => portalApi.myEsignList(),
})
const { data: detail, isLoading: detailLoading } = useQuery<any>({
queryKey: ['portal-esign-detail', selectedId],
queryFn: () => portalApi.esignDetail(selectedId!),
enabled: !!selectedId,
})
const signMutation = useMutation({
mutationFn: (id: string) => portalApi.signEsign(id),
onSuccess: () => {
toast.success('签署成功')
queryClient.invalidateQueries({ queryKey: ['portal-esign'] })
queryClient.invalidateQueries({ queryKey: ['portal-esign-detail', selectedId] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '签署失败'),
})
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
// 详情页
if (selectedId) {
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
return (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
>
<ChevronLeft className="w-4 h-4" />
</button>
{detailLoading ? (
<Card className="p-6">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-gray-100 rounded w-2/3" />
<div className="h-4 bg-gray-100 rounded w-1/3" />
<div className="h-20 bg-gray-100 rounded w-full" />
</div>
</Card>
) : detail ? (
<Card className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<FileText className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-base font-semibold truncate">{detail.documentTitle}</h1>
<div className="text-xs text-gray-500 mt-0.5">
{new Date(detail.createdAt).toLocaleString('zh-CN')}
</div>
</div>
{st && (
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
)}
</div>
{detail.remark && (
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
{detail.remark}
</div>
)}
{detail.documentContent && (
<div className="mb-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border rounded-md p-3 max-h-60 overflow-y-auto">
{detail.documentContent}
</div>
)}
{detail.contractId && (
<div className="mb-4 text-xs text-blue-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
</div>
)}
{detail.scene && SCENE_LABELS[detail.scene] && (
<div className="mb-4">
<span className={`px-2 py-0.5 rounded text-xs ${SCENE_LABELS[detail.scene].color}`}>{SCENE_LABELS[detail.scene].label}</span>
</div>
)}
{/* 签署操作 */}
<div className="border-t pt-4">
{detail.status === 'PENDING' ? (
<Button
className="w-full"
onClick={() => signMutation.mutate(detail.id)}
disabled={signMutation.isPending}
>
<PenTool className="w-4 h-4 mr-1" />
{signMutation.isPending ? '签署中...' : '确认签署'}
</Button>
) : detail.status === 'COMPLETED' ? (
<div className="text-center text-xs text-gray-500">
{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
</div>
) : (
<div className="text-center text-xs text-gray-400">
{st?.label}
</div>
)}
</div>
</Card>
) : (
<Card className="p-6">
<EmptyState title="记录不存在" description="该签署记录可能已被删除" />
</Card>
)}
</div>
)
}
// 列表页
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PenTool className="w-5 h-5 text-primary" />
<h1 className="text-base font-bold"></h1>
{pendingCount > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />{pendingCount}
</span>
)}
</div>
{isLoading ? (
<div className="text-center py-12 text-gray-400 text-sm">...</div>
) : list.length === 0 ? (
<Card className="p-6">
<EmptyState title="暂无签署任务" description="没有需要您签署的文件" />
</Card>
) : (
<div className="space-y-2">
{list.map((r: any) => {
const st = STATUS_MAP[r.status] || STATUS_MAP.PENDING
return (
<Card key={r.id} className="cursor-pointer hover:shadow-md transition-shadow">
<div onClick={() => setSelectedId(r.id)} className="flex items-center gap-3 p-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
r.status === 'PENDING' ? 'bg-amber-100' : r.status === 'COMPLETED' ? 'bg-green-100' : 'bg-gray-100'
}`}>
<FileText className={`w-5 h-5 ${
r.status === 'PENDING' ? 'text-amber-600' : r.status === 'COMPLETED' ? 'text-green-600' : 'text-gray-400'
}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate min-w-0">{r.documentTitle}</span>
{r.scene && SCENE_LABELS[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs flex-shrink-0 ${SCENE_LABELS[r.scene].color}`}>{SCENE_LABELS[r.scene].label}</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs flex-shrink-0 ${st.color}`}>
{st.icon}{st.label}
</span>
</div>
</Card>
)
})}
</div>
)}
</div>
)
}
+227
View File
@@ -0,0 +1,227 @@
/**
* //
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { GraduationCap, TrendingUp, AlertTriangle, CheckCircle, Clock, XCircle, ArrowLeft } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Button from '../../components/ui/Button'
type Tab = 'training' | 'performance' | 'disciplinary'
const TABS: { key: Tab; label: string; icon: any }[] = [
{ key: 'training', label: '培训记录', icon: GraduationCap },
{ key: 'performance', label: '绩效考核', icon: TrendingUp },
{ key: 'disciplinary', label: '违纪记录', icon: AlertTriangle },
]
const ACK_LABELS: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const ACK_COLORS: Record<string, string> = { PENDING: 'text-amber-600', SIGNED: 'text-green-600', REFUSED: 'text-red-600' }
const RESULT_LABELS: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
const TYPE_LABELS: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const SEVERITY_LABELS: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' }
const ACTION_LABELS: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
function fmtDate(d: string | Date | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('zh-CN')
}
export default function MyRecords() {
const [tab, setTab] = useState<Tab>('training')
return (
<div>
<div className="flex items-center gap-3 mb-4">
{TABS.map(t => {
const Icon = t.icon
const active = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${active ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
)
})}
</div>
{tab === 'training' && <TrainingTab />}
{tab === 'performance' && <PerformanceTab />}
{tab === 'disciplinary' && <DisciplinaryTab />}
</div>
)
}
function TrainingTab() {
const queryClient = useQueryClient()
const { data: records, isLoading } = useQuery({
queryKey: ['portal-training'],
queryFn: () => portalApi.myTraining(),
})
const signMut = useMutation({
mutationFn: (id: string) => portalApi.signTraining(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-training'] })
},
})
const refuseMut = useMutation({
mutationFn: (id: string) => portalApi.refuseTraining(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-training'] })
},
})
if (isLoading) return <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GraduationCap className="w-4 h-4 text-primary" />
<span className="font-medium">{r.topic}</span>
</div>
<span className={`text-xs ${ACK_COLORS[r.ackStatus] || 'text-gray-500'}`}>
{ACK_LABELS[r.ackStatus] || r.ackStatus}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{fmtDate(r.trainingDate)}</div>
<div>{r.trainer || '-'}</div>
<div>{r.duration}</div>
</div>
{r.content && <div className="text-sm text-gray-600">{r.content}</div>}
{r.remark && <div className="text-xs text-gray-400">{r.remark}</div>}
{r.ackStatus === 'PENDING' && (
<div className="flex gap-2 pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => refuseMut.mutate(r.id)} disabled={refuseMut.isPending}>
<XCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
function PerformanceTab() {
const queryClient = useQueryClient()
const { data: records, isLoading } = useQuery({
queryKey: ['portal-performance'],
queryFn: () => portalApi.myPerformance(),
})
const signMut = useMutation({
mutationFn: (id: string) => portalApi.signPerformance(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-performance'] })
},
})
if (isLoading) return <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
<span className="font-medium">{r.period} </span>
</div>
<span className={`text-xs ${r.employeeAck ? 'text-green-600' : 'text-amber-600'}`}>
{r.employeeAck ? '已签字' : '待签字'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{r.score}</div>
<div>{r.grade}</div>
<div>{RESULT_LABELS[r.result] || r.result}</div>
</div>
{r.summary && <div className="text-sm text-gray-600">{r.summary}</div>}
{r.improvementPlan && <div className="text-sm text-orange-600">{r.improvementPlan}</div>}
{r.reviewer && <div className="text-xs text-gray-400">{r.reviewer}</div>}
{!r.employeeAck && (
<div className="pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
function DisciplinaryTab() {
const queryClient = useQueryClient()
const { data: records, isLoading } = useQuery({
queryKey: ['portal-disciplinary'],
queryFn: () => portalApi.myDisciplinary(),
})
const signMut = useMutation({
mutationFn: (id: string) => portalApi.signDisciplinary(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-disciplinary'] })
},
})
if (isLoading) return <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="font-medium">{TYPE_LABELS[r.violationType] || r.violationType}</span>
</div>
<span className={`text-xs ${r.employeeAck ? 'text-green-600' : 'text-amber-600'}`}>
{r.employeeAck ? '已签字' : '待签字'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{fmtDate(r.violationDate)}</div>
<div>{SEVERITY_LABELS[r.severity] || r.severity}</div>
<div>{ACTION_LABELS[r.action] || r.action}</div>
</div>
<div className="text-sm text-gray-600">{r.description}</div>
{r.actionDetail && <div className="text-xs text-gray-500">{r.actionDetail}</div>}
{r.witness && <div className="text-xs text-gray-400">{r.witness}</div>}
{!r.employeeAck && (
<div className="pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
+2 -2
View File
@@ -131,8 +131,8 @@ export default function Onboarding() {
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="请输入手机号" maxLength={11} />
</div>
<div>
<Label> *</Label>
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入身份证号" maxLength={18} />
<Label> *</Label>
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入证件号码" maxLength={18} />
</div>
<div>
<Label></Label>
+3 -1
View File
@@ -3,12 +3,14 @@
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
import { DollarSign, FileText, ScrollText, LogOut, PenTool, ClipboardList } from 'lucide-react'
const navItems = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
{ path: '/portal/contract', label: '我的合同', icon: FileText },
{ path: '/portal/esign', label: '电子签署', icon: PenTool },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
{ path: '/portal/records', label: '我的记录', icon: ClipboardList },
]
export default function PortalNav() {
+82 -4
View File
@@ -1,10 +1,10 @@
/**
*
*/
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { UserX, Clock, FileText } from 'lucide-react'
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon, Download } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
@@ -38,6 +38,8 @@ export default function ResignationApply() {
expectedDate: '',
remark: '',
})
const [attachments, setAttachments] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
/** 查询离职申请状态 */
const { data: records = [], isLoading } = useQuery<any[]>({
@@ -49,13 +51,14 @@ export default function ResignationApply() {
/** 提交离职申请 */
const submitMutation = useMutation({
mutationFn: async (data: { reason: string; expectedDate: string; remark: string }) => {
mutationFn: async (data: { reason: string; expectedDate: string; remark: string; attachments?: string[] }) => {
return await portalApi.resignationSubmit(data)
},
onSuccess: () => {
toast.success('离职申请已提交,请等待HR审批')
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
setForm({ reason: '', expectedDate: '', remark: '' })
setAttachments([])
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '提交失败')
@@ -79,7 +82,24 @@ export default function ResignationApply() {
const handleSubmit = () => {
if (!form.reason) { toast.error('请选择离职原因'); return }
if (!form.expectedDate) { toast.error('请选择预计离职日期'); return }
submitMutation.mutate(form)
submitMutation.mutate({ ...form, attachments })
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
Array.from(files).forEach(file => {
if (file.size > 5 * 1024 * 1024) {
toast.error(`${file.name} 超过5MB限制`)
return
}
const reader = new FileReader()
reader.onload = () => {
setAttachments(prev => [...prev, reader.result as string])
}
reader.readAsDataURL(file)
})
if (fileInputRef.current) fileInputRef.current.value = ''
}
const hasPending = records.some((r: any) => r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL')
@@ -125,6 +145,40 @@ export default function ResignationApply() {
placeholder="补充说明(选填)"
/>
</div>
<div>
<Label>5</Label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileUpload}
/>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 px-3 py-2 rounded-md border border-dashed border-gray-300 text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
>
<Camera className="w-4 h-4" />
</button>
{attachments.map((img, i) => (
<div key={i} className="relative w-16 h-16 rounded-md overflow-hidden border">
<img src={img} alt={`附件${i + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0 right-0 bg-black/50 text-white rounded-bl p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
<div className="text-xs text-gray-400 mt-1">HR审批时可查看</div>
</div>
<Button onClick={handleSubmit} disabled={submitMutation.isPending} className="w-full">
{submitMutation.isPending ? '提交中...' : '提交离职申请'}
</Button>
@@ -151,6 +205,7 @@ export default function ResignationApply() {
{records.map((r: any) => {
const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT
const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL'
const canDownload = r.status === 'COMPLETED'
return (
<div key={r.id} className="p-3 rounded-lg border border-gray-100">
<div className="flex items-center justify-between mb-2">
@@ -169,6 +224,29 @@ export default function ResignationApply() {
<div className="text-xs text-gray-500 mt-1">{r.remark}</div>
)}
</div>
{canDownload && (
<div className="mt-2 pt-2 border-t border-gray-50">
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const blob = await portalApi.downloadCertificate(r.id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `离职证明.doc`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载失败')
}
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
)}
{canWithdraw && (
<div className="mt-2 pt-2 border-t border-gray-50">
<Button
+4 -4
View File
@@ -10,7 +10,7 @@ import { Paperclip, Trash2, Eye, Download } from "lucide-react"
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
@@ -49,8 +49,8 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600', CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
@@ -65,7 +65,7 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
<Card>
<div className="flex gap-2 mb-3 flex-nowrap items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-32 shrink-0">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="CERTIFICATE"></option><option value="CONTRACT"></option><option value="PHOTO"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
+163 -13
View File
@@ -1,19 +1,22 @@
import { QRCodeSVG } from "qrcode.react"
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi } from '../../lib/api-services'
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"
import { Input, Label, Select } from "../../components/ui/Input"
import { AlertTriangle, Paperclip, Trash2, Eye, Download } from "lucide-react"
import { AlertTriangle, Paperclip, Trash2, Eye, Download, Copy } from "lucide-react"
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' | 'OTHER'>('ID_CARD')
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
@@ -46,8 +49,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600', CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
@@ -57,6 +60,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
const [form, setForm] = useState({
department: profile.department || '',
position: profile.position || '',
gender: profile.gender || '男',
femaleWorkerType: profile.femaleWorkerType || '',
phone: profile.phone || '',
@@ -85,13 +89,58 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
queryClient.invalidateQueries({ queryKey: ['roster'] })
setEditing(false)
},
onError: (err: any) => {
const details = err?.response?.data?.error?.details
if (details?.length > 0) {
toast.error(details.map((d: any) => `${d.path}: ${d.message}`).join(''))
} else {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
},
})
const handleSave = () => {
// 查询社保费用明细(按险种分别计算)
const { data: socialDetail } = useQuery<any>({
queryKey: ['social-calc', profile.id, profile.socialInsBase, profile.city],
queryFn: async () => {
if (!profile.socialInsBase || !profile.city) return null
try {
return await socialInsuranceApi.calculate(Number(profile.socialInsBase), profile.city)
} catch { return null }
},
enabled: !editing && !!profile.socialInsBase && !!profile.city,
})
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,
@@ -112,8 +161,10 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
education: form.education || undefined,
position: form.position || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
if (statusUpdate) data.status = statusUpdate
updateMutation.mutate(data)
}
@@ -124,9 +175,10 @@ 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 || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
@@ -195,7 +247,21 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{personalFields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2">{f.value}</span>
<span className="font-medium text-right truncate ml-2 flex items-center gap-1">
{f.value}
{f.label === '证件号码' && profile.idCardNumber && (
<button
type="button"
className="text-gray-400 hover:text-primary transition-colors shrink-0"
title="复制证件号码"
onClick={() => {
copyToClipboard(profile.idCardNumber, '已复制证件号码')
}}
>
<Copy className="w-3 h-3" />
</button>
)}
</span>
</div>
))}
</div>
@@ -211,11 +277,75 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
))}
</div>
</div>
{profile.socialInsBase != null && (
<div className="pt-3 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-2"></h3>
<div className="grid md:grid-cols-2 gap-x-6 gap-y-1 text-xs">
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(profile.socialInsBase)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(profile.housingFundBase || 0)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">16%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.16)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">2%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.02)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.5%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.5%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.2%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.002)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.008)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">7%</span>
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">7%</span>
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between font-medium pt-1">
<span></span>
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.105 + (profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between font-medium pt-1">
<span></span>
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.255 + (profile.housingFundBase || 0) * 0.07)}</span>
</div>
</div>
<div className="text-xs text-gray-400 mt-1"></div>
</div>
)}
</div>
) : (
<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 === '女' && (
@@ -223,6 +353,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
)}
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label></Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
<div><Label></Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
@@ -254,6 +385,25 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
{socialDetail?.items?.length > 0 && (
<div className="md:col-span-4 mt-2">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="grid md:grid-cols-5 gap-2">
{socialDetail.items.map((item: any) => (
<div key={item.name} className="px-2 py-1.5 rounded bg-gray-50 text-xs">
<div className="font-medium text-gray-700">{item.name}</div>
<div className="text-gray-500 mt-0.5"> ¥{fmt(item.orgAmount)}{item.orgRate}%</div>
<div className="text-gray-500"> ¥{fmt(item.empAmount)}{item.empRate}%</div>
</div>
))}
</div>
{socialDetail.capped && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.floored && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.medicalBase && socialDetail.medicalBase !== socialDetail.actualBase && (
<div className="text-xs text-blue-600 mt-1">¥{fmt(socialDetail.medicalBase)}</div>
)}
</div>
)}
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
@@ -263,11 +413,11 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label>/</Label>
@@ -358,7 +508,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{!editing && (
<div className="flex gap-2 items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="CERTIFICATE"></option><option value="CONTRACT"></option><option value="PHOTO"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">

Some files were not shown because too many files have changed in this diff Show More