init: AI HR Compliance Assistant
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,762 @@
|
||||
# 劳动用工合规助手 — 开发任务清单
|
||||
|
||||
> **文档编号**: 2-task.md
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-07-23
|
||||
> **状态**: 开发中
|
||||
> **依据**: 0-req.md v3.0 需求规格说明书 / 1-prd.md v1.0 产品需求文档
|
||||
|
||||
---
|
||||
|
||||
## 任务总览
|
||||
|
||||
| 阶段 | 内容 | 预估工期 | 任务数 |
|
||||
|------|------|---------|--------|
|
||||
| P0 | 项目搭建 + 路由骨架 + Prisma Schema | 2天 | 8 | ✅ 已完成 |
|
||||
| P1 | 认证体系(注册/登录/JWT/路由守卫) | 2天 | 7 | ✅ 已完成 |
|
||||
| P2 | 首页风险总览 + 风险检测引擎 | 2天 | 6 | ✅ 已完成 |
|
||||
| P3 | 合同管理(列表/添加/续签/纸质电子) | 3天 | 10 | ✅ 已完成 |
|
||||
| P4 | 钱的计算(3 Tab 计算器 + 加班费保存 + 工资条管理) | 2天 | 5 | ✅ 已完成 |
|
||||
| P5 | 解聘助手(5步向导 + 禁止检查) | 2天 | 6 | ✅ 已完成 |
|
||||
| P6 | AI 合规顾问(问答/预测/审查/案例 + RAG) | 4天 | 9 | ✅ 已完成 |
|
||||
| P7 | 员工端(密码/验证码登录 + 工资条 + 合同 + 入职填报 + 合同确认) | 3天 | 10 | ✅ 已完成 |
|
||||
| P8 | 系统设置 + 新手引导 + 空状态 | 1天 | 5 | ✅ 已完成 |
|
||||
| P9 | 移动端适配 + 联调 | 2天 | 4 | ✅ 已完成 |
|
||||
| P10 | 部署上线 + 验证 | 1天 | 4 | ⏳ 进行中 |
|
||||
| P11 | 功能补齐(社保公积金 + 到期提醒 + 批量工资条 + Excel导入 + 员工档案附件) | 3天 | 10 | ✅ 已完成 |
|
||||
| **合计** | | **~27天** | **84** | |
|
||||
|
||||
---
|
||||
|
||||
## P0 — 项目搭建 + 路由骨架 + Prisma Schema(2天)
|
||||
|
||||
### 前端
|
||||
|
||||
- [x] **T-P0-01** 初始化前端项目
|
||||
- Vite + React 18 + TypeScript
|
||||
- 安装 TailwindCSS + PostCSS
|
||||
- 配置路径别名 `@/` → `src/`
|
||||
- 安装核心依赖:react-router-dom, axios, zustand, @tanstack/react-query, react-hook-form, zod, lucide-react, qrcode.react
|
||||
- **产出**: `package.json`, `vite.config.ts`, `tailwind.config.ts`, `tsconfig.json`
|
||||
|
||||
- [x] **T-P0-02** 前端项目结构搭建
|
||||
- 创建目录结构:`components/`, `pages/`, `hooks/`, `lib/`, `store/`, `types/`
|
||||
- 创建 `App.tsx` 路由骨架(含管理端 + 员工端路由定义)
|
||||
- 创建 `main.tsx` 入口
|
||||
- 创建 `lib/api.ts`(Axios 实例 + 请求/响应拦截器)
|
||||
- 创建 `types/index.ts`(TypeScript 类型定义)
|
||||
- **产出**: 项目目录结构 + 路由配置
|
||||
|
||||
- [x] **T-P0-03** 前端布局组件
|
||||
- `TopNav.tsx`:顶部导航栏(Logo + 5 Tab + 风险角标 + 用户头像下拉)
|
||||
- `MobileTabBar.tsx`:移动端底部导航
|
||||
- `PageContainer.tsx`:主内容区容器(max-width 960px 居中)
|
||||
- `ui/Button.tsx`, `ui/Card.tsx`, `ui/Input.tsx`, `ui/Select.tsx`, `ui/Modal.tsx`, `ui/Signal.tsx`, `ui/EmptyState.tsx`
|
||||
- **产出**: 通用组件库
|
||||
|
||||
### 后端
|
||||
|
||||
- [x] **T-P0-04** 初始化后端项目
|
||||
- Node.js + Express + TypeScript
|
||||
- 安装核心依赖:prisma, @prisma/client, zod, jsonwebtoken, bcryptjs, cors, helmet, morgan, express-rate-limit
|
||||
- 配置 ts-node-dev 热重载
|
||||
- **产出**: `package.json`, `tsconfig.json`, `.env.example`
|
||||
|
||||
- [x] **T-P0-05** 后端项目结构搭建
|
||||
- 创建目录结构:`routes/`, `middleware/`, `services/`, `lib/`, `validators/`, `jobs/`
|
||||
- `app.ts`:Express 应用(CORS + helmet + JSON 解析 + 路由挂载)
|
||||
- `index.ts`:服务入口
|
||||
- **产出**: 后端骨架 + 健康检查接口 `/health`
|
||||
|
||||
- [x] **T-P0-06** Prisma Schema 编写
|
||||
- 编写完整 `schema.prisma`:Organization, User, Employee, LaborContract, OvertimeRecord, TerminationRecord, RiskItem, AuditLog, Payslip, OnboardingLink, ContractConfirmLink
|
||||
- 定义所有枚举:Plan, Role, EmployeeStatus, ContractType, SignMethod, RiskType, RiskLevel, RiskStatus, TerminationReason, RiskAssessment, OnboardingStatus, ContractConfirmStatus
|
||||
- 配置 PostgreSQL 数据源
|
||||
- **产出**: `prisma/schema.prisma`
|
||||
|
||||
- [x] **T-P0-07** 数据库迁移 + 种子数据
|
||||
- 运行 `prisma migrate dev` 生成初始迁移
|
||||
- 编写 `prisma/seed.ts` 种子数据(测试企业 + 员工 + 合同)
|
||||
- 配置 `prisma.ts` 客户端单例
|
||||
- **产出**: 数据库表结构 + 测试数据
|
||||
|
||||
- [x] **T-P0-08** 中间件骨架
|
||||
- `auth.ts`:JWT 校验中间件(从 Header 提取 Token → 验证 → 注入 req.user)
|
||||
- `orgFilter.ts`:多租户中间件(从 req.user 提取 orgId → 注入 req.orgId)
|
||||
- `errorHandler.ts`:统一错误处理(Zod 错误 → 422,Prisma 错误 → 400,其他 → 500)
|
||||
- `rateLimit.ts`:限流中间件(基于 express-rate-limit)
|
||||
- `auditLog.ts`:审计日志中间件(记录关键操作)
|
||||
- **产出**: 5 个中间件文件
|
||||
|
||||
---
|
||||
|
||||
## P1 — 认证体系(2天)
|
||||
|
||||
- [x] **T-P1-01** 后端:注册接口
|
||||
- `POST /api/v1/auth/register`
|
||||
- 输入校验(Zod):企业名称、手机号、密码(8位+)
|
||||
- 逻辑:创建 Organization(plan=free, maxEmployees=20)+ User(role=admin, bcrypt 加密)
|
||||
- 返回:Access Token(2h)+ Refresh Token(7d)
|
||||
- 限流:同一 IP 每小时 5 次
|
||||
- **产出**: `auth.routes.ts` + `auth.service.ts` + `auth.validator.ts`
|
||||
|
||||
- [x] **T-P1-02** 后端:登录接口
|
||||
- `POST /api/v1/auth/login`
|
||||
- 输入校验:手机号、密码
|
||||
- 逻辑:查询 User → bcrypt 比对 → 签发 Token
|
||||
- 限流:同一 IP 每分钟 5 次
|
||||
- **产出**: 登录逻辑
|
||||
|
||||
- [x] **T-P1-03** 后端:Token 刷新 + 当前用户
|
||||
- `POST /api/v1/auth/refresh`:校验 Refresh Token → 签发新 Access Token
|
||||
- `GET /api/v1/auth/me`:返回当前用户信息 + 组织信息
|
||||
- **产出**: Token 刷新逻辑
|
||||
|
||||
- [x] **T-P1-04** 后端:JWT 工具
|
||||
- `lib/jwt.ts`:签发/验证 Access Token + Refresh Token
|
||||
- 密钥从环境变量读取
|
||||
- **产出**: `jwt.ts`
|
||||
|
||||
- [x] **T-P1-05** 前端:注册页面
|
||||
- `/register` 页面
|
||||
- 表单:企业名称、手机号、密码、确认密码
|
||||
- React Hook Form + Zod 校验
|
||||
- 注册成功 → 存储 Token → 跳转首页
|
||||
- **产出**: `Register.tsx`
|
||||
|
||||
- [x] **T-P1-06** 前端:登录页面 + 路由守卫
|
||||
- `/login` 页面
|
||||
- 表单:手机号、密码
|
||||
- `useAuth` Hook(Zustand store:user, token, isAuthenticated)
|
||||
- `ProtectedRoute`:未登录 → 跳转 `/login`
|
||||
- `PublicRoute`:已登录 → 跳转 `/`
|
||||
- Axios 拦截器:401 → 自动刷新 Token / 跳转登录
|
||||
- **产出**: `Login.tsx` + `useAuth.ts` + `authStore.ts` + 路由守卫
|
||||
|
||||
- [x] **T-P1-07** 前端:忘记密码页面
|
||||
- `/forgot-password` 页面
|
||||
- 手机号 + 验证码 + 新密码
|
||||
- **产出**: `ForgotPassword.tsx`
|
||||
|
||||
---
|
||||
|
||||
## P2 — 首页风险总览 + 风险检测引擎(2天)
|
||||
|
||||
- [x] **T-P2-01** 后端:Dashboard 数据聚合接口
|
||||
- `GET /api/v1/dashboard`
|
||||
- 聚合:员工数、高风险数、待办数、月加班费
|
||||
- 生成待办列表(从 RiskItem 查询 pending 状态)
|
||||
- 风险分布统计(按 type 分组)
|
||||
- AI 预测数据(从缓存读取,P6 实现)
|
||||
- **产出**: `dashboard.routes.ts` + `dashboard.service.ts`
|
||||
|
||||
- [x] **T-P2-02** 后端:风险检测引擎
|
||||
- `lib/riskEngine.ts`
|
||||
- 合同风险检测:未签合同(>30天 🔴 / >365天 视为无固定期限 🔴)、即将到期(≤30天 🟡)、已到期 🔴
|
||||
- 试用期风险检测:试用期超法定上限
|
||||
- 加班风险检测:月加班 > 36h
|
||||
- 解聘风险检测:禁止解聘情形(孕期/工伤/医疗期)
|
||||
- 触发时机:数据变更时实时检测 + 定时全量扫描
|
||||
- **产出**: `riskEngine.ts`
|
||||
|
||||
- [x] **T-P2-03** 后端:风险 CRUD 接口
|
||||
- `GET /api/v1/risks`:风险列表(分页 + 类型筛选 + 状态筛选)
|
||||
- `PUT /api/v1/risks/:id`:更新风险状态(resolved / ignored + 备注)
|
||||
- **产出**: `risk.routes.ts` + `risk.service.ts`
|
||||
|
||||
- [x] **T-P2-04** 后端:定时风险扫描任务
|
||||
- `jobs/riskScan.ts`:每日凌晨 2:00 全量扫描
|
||||
- 使用 node-cron 调度
|
||||
- 扫描所有企业的员工/合同 → 生成/更新 RiskItem
|
||||
- **产出**: `riskScan.ts`
|
||||
|
||||
- [x] **T-P2-05** 前端:首页风险总览页面
|
||||
- `/` Dashboard 页面
|
||||
- 一句话状态("早上好!今天有 N 件事需要处理")
|
||||
- 数字卡片:员工数 / 高风险数 / 待办数 / 月加班费
|
||||
- 待办列表:每条含风险等级颜色 + 标题 + 「去处理」按钮
|
||||
- 风险分布进度条(合同/工资/解聘)
|
||||
- AI 风险预测卡片(P6 实现后接入)
|
||||
- **产出**: `Dashboard.tsx` + `TodoList.tsx` + `ProgressBar.tsx`
|
||||
|
||||
- [x] **T-P2-06** 前端:风险角标组件
|
||||
- 顶部导航栏红色角标,显示待处理风险总数
|
||||
- 点击跳转首页
|
||||
- 数据来源:Dashboard 接口或独立计数接口
|
||||
- **产出**: `TopNav.tsx` 集成角标
|
||||
|
||||
---
|
||||
|
||||
## P3 — 合同管理(3天)
|
||||
|
||||
- [x] **T-P3-01** 后端:员工 CRUD 接口
|
||||
- `GET /api/v1/employees`:列表(分页 + 搜索 + 部门筛选)
|
||||
- `POST /api/v1/employees`:添加员工(含合同信息 + AES-256 加密工资)
|
||||
- `GET /api/v1/employees/:id`:详情(含合同 + 风险)
|
||||
- `PUT /api/v1/employees/:id`:编辑
|
||||
- `DELETE /api/v1/employees/:id`:软删除(status=resigned)
|
||||
- **产出**: `employee.routes.ts` + `employee.service.ts` + `employee.validator.ts`
|
||||
|
||||
- [x] **T-P3-02** 后端:AES-256 加密工具
|
||||
- `lib/crypto.ts`:加密/解密工资字段
|
||||
- 密钥从环境变量 `ENCRYPTION_KEY` 读取
|
||||
- **产出**: `crypto.ts`
|
||||
|
||||
- [x] **T-P3-03** 后端:合同状态计算
|
||||
- `lib/contractStatus.ts`
|
||||
- 输入:signDate, startDate, endDate, contractType, renewalCount, hireDate
|
||||
- 输出:status + statusText + riskLevel
|
||||
- 逻辑:未签/即将到期/已到期/正常/无固定期限
|
||||
- **产出**: `contractStatus.ts`
|
||||
|
||||
- [x] **T-P3-04** 后端:试用期合法性校验
|
||||
- 合同期 < 3月 → 不能约定试用期
|
||||
- 合同期 3月~1年 → 试用期 ≤ 1月
|
||||
- 合同期 1~3年 → 试用期 ≤ 2月
|
||||
- 合同期 ≥ 3年 → 试用期 ≤ 6月
|
||||
- **产出**: 集成到 `employee.validator.ts`
|
||||
|
||||
- [x] **T-P3-05** 后端:批量续签接口
|
||||
- `POST /api/v1/contracts/batch-renew`
|
||||
- 输入:合同 ID 列表 + 新期限
|
||||
- 逻辑:更新 endDate + renewalCount++ + 重新检测风险
|
||||
- **产出**: `contract.service.ts` 续签逻辑
|
||||
|
||||
- [x] **T-P3-06** 后端:合同附件上传
|
||||
- `POST /api/v1/contracts/:id/attachment`
|
||||
- 接收 multipart 文件(纸质合同扫描件)
|
||||
- 存储到 Supabase Storage / 本地临时目录
|
||||
- 更新合同记录 attachmentName + attachmentUrl
|
||||
- **产出**: 文件上传逻辑
|
||||
|
||||
- [x] **T-P3-07** 前端:合同管理列表页
|
||||
- `/contracts` 页面
|
||||
- 员工合同列表:姓名 / 部门 / 合同状态信号灯 / 到期日 / 操作
|
||||
- 搜索框 + 部门筛选
|
||||
- 信号灯组件(🔴🟡🟢)
|
||||
- **产出**: `Contracts.tsx`
|
||||
|
||||
- [x] **T-P3-08** 前端:添加/编辑员工表单
|
||||
- 模态框表单
|
||||
- Step 1 基本信息:姓名*、部门*、手机号、入职日期*、月工资*、性别
|
||||
- Step 2 合同信息:合同类型*、签订方式(纸质/电子)、签订日期、起止日期、试用期月数、试用期工资
|
||||
- 纸质合同:显示文件上传按钮
|
||||
- 电子合同:显示合同编号 + 链接输入
|
||||
- 试用期实时校验(红色提示)
|
||||
- 特殊标记:孕期/工伤/医疗期 复选框
|
||||
- **产出**: `EmployeeForm.tsx`
|
||||
|
||||
- [x] **T-P3-09** 前端:一键续签弹窗
|
||||
- 选中即将到期的合同 → 点击「续签」
|
||||
- 弹窗:显示当前合同信息 + 选择新期限
|
||||
- 确认 → 调用批量续签接口 → 刷新列表
|
||||
- **产出**: `RenewModal.tsx`
|
||||
|
||||
- [x] **T-P3-10** 前端:合同详情页/弹窗
|
||||
- 显示员工信息 + 合同完整信息 + 风险卡片
|
||||
- 纸质合同:查看扫描件
|
||||
- 电子合同:查看合同链接
|
||||
- 操作按钮:编辑 / 续签 / 发送确认二维码(P7 实现)
|
||||
- **产出**: `ContractDetail.tsx`
|
||||
|
||||
---
|
||||
|
||||
## P4 — 钱的计算(2天)
|
||||
|
||||
- [x] **T-P4-01** 前端:加班费计算器(含员工关联 + 月份选择 + 保存记录)
|
||||
- `/money` Tab 1
|
||||
- 输入:月工资、工作日加班小时、休息日加班小时、节假日加班小时
|
||||
- 公式:hourlyWage = monthlyWage / 21.75 / 8
|
||||
- weekdayPay = hourlyWage × 1.5 × weekdayHours
|
||||
- weekendPay = hourlyWage × 2.0 × weekendHours
|
||||
- holidayPay = hourlyWage × 3.0 × holidayHours
|
||||
- 实时计算,右侧显示结果
|
||||
- 总加班 > 36h → 黄色警告
|
||||
- **产出**: `OvertimeCalculator.tsx` + `lib/calculator.ts`
|
||||
|
||||
- [x] **T-P4-02** 前端:双倍工资计算器
|
||||
- `/money` Tab 2
|
||||
- 输入:月工资、入职日期、合同签订日期(可选)
|
||||
- 公式:未签或超 30 天签订 → 起算入职+1月 → 截止入职+1年或签订日 → 双倍工资差额
|
||||
- 实时计算
|
||||
- **产出**: `DoublePayCalculator.tsx`
|
||||
|
||||
- [x] **T-P4-03** 前端:经济补偿金计算器
|
||||
- `/money` Tab 3
|
||||
- 输入:入职日期、离职日期、月平均工资、离职原因、社平工资(选填)
|
||||
- 公式:工作年限 → 补偿月数 → 封顶限制 → 经济补偿金 / 违法解除赔偿金(×2)
|
||||
- 实时计算
|
||||
- **产出**: `CompensationCalculator.tsx`
|
||||
|
||||
- [x] **T-P4-04** 后端:加班记录 CRUD + 工资条管理(`payroll.routes.ts`)
|
||||
- `GET /api/v1/overtime`:加班记录列表
|
||||
- `POST /api/v1/overtime`:添加加班记录
|
||||
- **产出**: `overtime.routes.ts` + `overtime.service.ts`
|
||||
|
||||
- [x] **T-P4-05** 前端:计算器工具函数 + 工资条管理 Tab
|
||||
- `lib/calculator.ts`:纯函数,输入输出明确
|
||||
- 编写单元测试验证计算公式正确性
|
||||
- 边界用例:0 加班、36h 临界值、社平工资 3 倍封顶
|
||||
- **产出**: `calculator.ts` + `calculator.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## P5 — 解聘助手(2天)
|
||||
|
||||
- [x] **T-P5-01** 后端:解聘记录 CRUD
|
||||
- `GET /api/v1/termination`:解聘记录列表
|
||||
- `POST /api/v1/termination`:创建解聘记录
|
||||
- 逻辑:保存向导数据 + 更新员工状态为 resigned + 记录审计日志
|
||||
- **产出**: `termination.routes.ts` + `termination.service.ts` + `termination.validator.ts`
|
||||
|
||||
- [x] **T-P5-02** 前端:解聘向导 Step 1 — 选择解聘原因
|
||||
- `/termination` 页面
|
||||
- 5 个选项卡片:协商解除 / 员工犯错 / 员工没犯错但干不了 / 公司裁员 / 合同到期不续签
|
||||
- 每个选项含简短说明
|
||||
- **产出**: `Termination.tsx` Step 1
|
||||
|
||||
- [x] **T-P5-03** 前端:解聘向导 Step 2 — 选择员工 + 禁止情形检查
|
||||
- 员工选择下拉框(仅在职员工)
|
||||
- 选择后自动检查:isPregnant / isWorkInjured / isInMedicalPeriod
|
||||
- 命中禁止情形 → 红色警告弹窗 + 「我已了解风险,继续操作」
|
||||
- **产出**: Step 2 + 禁止情形检查逻辑
|
||||
|
||||
- [x] **T-P5-04** 前端:解聘向导 Step 3 — 合规检查清单
|
||||
- 根据解聘原因动态生成检查项
|
||||
- 协商解除:是否支付补偿金 / 是否签署协议
|
||||
- 员工犯错:是否有规章制度 / 是否有证据 / 是否通知工会
|
||||
- 员工没犯错:是否提前30天通知 / 是否经过培训调岗
|
||||
- 公司裁员:是否提前30天向工会说明 / 是否听取意见 / 是否报劳动部门
|
||||
- 合同到期:是否提前通知 / 是否支付补偿金
|
||||
- 每项 ✅/❌ 选择
|
||||
- **产出**: Step 3 + 动态检查项规则
|
||||
|
||||
- [x] **T-P5-05** 前端:解聘向导 Step 4 — 补偿金计算 + Step 5 — 确认提交
|
||||
- Step 4:自动填充员工工资 + 入职日期 → 计算补偿金(复用 P4 计算逻辑)
|
||||
- Step 5:汇总信息确认 → 提交保存
|
||||
- 进度条显示 1/5 ~ 5/5
|
||||
- **产出**: Step 4 + Step 5
|
||||
|
||||
- [x] **T-P5-06** 前端:解聘历史记录
|
||||
- `/termination` 页面底部
|
||||
- 历史记录列表:员工名 / 解聘日期 / 原因 / 补偿金 / 风险等级
|
||||
- 点击查看详情
|
||||
- **产出**: `TerminationHistory.tsx`
|
||||
|
||||
---
|
||||
|
||||
## P6 — AI 合规顾问(4天)
|
||||
|
||||
- [x] **T-P6-01** 后端:DashScope SDK 封装
|
||||
- `lib/dashscope.ts`
|
||||
- 封装通义千问 API 调用(兼容 OpenAI 格式)
|
||||
- 支持 qwen-plus(日常问答)和 qwen-max(复杂任务)
|
||||
- 支持 SSE 流式输出
|
||||
- API Key 从环境变量 `DASHSCOPE_API_KEY` 读取
|
||||
- **产出**: `dashscope.ts`
|
||||
|
||||
- [x] **T-P6-02** 后端:RAG 知识库 — 法律条文向量化
|
||||
- `services/rag.service.ts`
|
||||
- 收集劳动法/劳动合同法/司法解释/地方条例文本
|
||||
- 使用 DashScope text-embedding-v2 生成向量
|
||||
- 存储到 Supabase pgvector
|
||||
- 提供语义搜索接口(输入问题 → 检索相关法条)
|
||||
- **产出**: `rag.service.ts` + 知识库数据
|
||||
|
||||
- [x] **T-P6-03** 后端:智能问答接口(SSE)
|
||||
- `POST /api/v1/ai/chat`
|
||||
- 逻辑:
|
||||
1. 构建系统 Prompt(劳动法专家 + 人话风格)
|
||||
2. RAG 检索相关法条
|
||||
3. 注入企业数据上下文(员工数/风险项/合同状态)
|
||||
4. 调用 qwen-plus SSE 流式返回
|
||||
- SSE 事件格式:`data: {"type":"chunk","content":"xxx"}`
|
||||
- 结束事件:`data: {"type":"done","legalBasis":"..."}`
|
||||
- **产出**: `ai.routes.ts` + `ai.service.ts`
|
||||
|
||||
- [x] **T-P6-04** 后端:风险预测接口
|
||||
- `GET /api/v1/ai/prediction`
|
||||
- 逻辑:
|
||||
1. 查询未来 30 天到期合同
|
||||
2. 查询入职满 1 年未签合同员工
|
||||
3. 分析上月加班趋势
|
||||
4. 调用 LLM 生成优先级建议
|
||||
- 缓存 24h
|
||||
- 定时任务:每日凌晨生成
|
||||
- **产出**: 预测逻辑 + `jobs/aiPrediction.ts`
|
||||
|
||||
- [x] **T-P6-05** 后端:合同审查接口
|
||||
- `POST /api/v1/ai/contract-review`
|
||||
- 输入:合同文本(粘贴或文件解析)
|
||||
- 逻辑:调用 qwen-max 逐条分析 → 标注红/黄/绿 + 修改建议 → 合规评分
|
||||
- **产出**: 合同审查逻辑
|
||||
|
||||
- [x] **T-P6-06** 后端:案例匹配接口
|
||||
- `POST /api/v1/ai/case-match`
|
||||
- 输入:争议情况描述
|
||||
- 逻辑:text-embedding-v2 向量化 → pgvector 检索 top 5 → qwen-max 分析败诉概率
|
||||
- **产出**: 案例匹配逻辑
|
||||
|
||||
- [x] **T-P6-07** 后端:AI 使用次数限制
|
||||
- 中间件:每次 AI 请求前检查当月已用次数
|
||||
- 按 `orgId + 月份 + 类型` 统计
|
||||
- free: 10 问答 / 3 审查 / 3 案例
|
||||
- pro: 100 / 20 / 20
|
||||
- enterprise: 无限
|
||||
- 超限 → 429 + 提示升级
|
||||
- **产出**: AI 限流中间件
|
||||
|
||||
- [x] **T-P6-08** 前端:AI 顾问页面 — 智能问答
|
||||
- `/ai-assistant` 页面
|
||||
- 聊天界面:消息列表 + 输入框
|
||||
- 预设问题快捷按钮("试用期最长多久?" "未签合同怎么办?")
|
||||
- SSE 流式接收:逐字显示打字机效果
|
||||
- 法律依据折叠展示
|
||||
- 多轮对话(保留上下文 messages)
|
||||
- **产出**: `AIAssistant.tsx` 聊天 Tab
|
||||
|
||||
- [x] **T-P6-09** 前端:AI 顾问页面 — 合同审查 + 案例匹配
|
||||
- 合同审查 Tab:文本框粘贴合同 / 文件上传 → 提交 → 逐条标注展示 + 合规评分
|
||||
- 案例匹配 Tab:描述争议情况 → 提交 → 相似案例卡片列表 + 败诉概率 + 赔偿预估
|
||||
- **产出**: 合同审查 Tab + 案例匹配 Tab
|
||||
|
||||
---
|
||||
|
||||
## P7 — 员工端(3天)
|
||||
|
||||
- [x] **T-P7-01** 后端:员工端认证(密码登录 + 验证码登录)
|
||||
- `POST /api/v1/portal/auth/login`:手机号 + 密码(bcrypt 校验员工密码)
|
||||
- `POST /api/v1/portal/auth/send-code`:发送验证码(v1.0 页面内显示,存 Redis/内存)
|
||||
- `POST /api/v1/portal/auth/verify`:验证码登录
|
||||
- `POST /api/v1/portal/auth/change-password`:修改密码
|
||||
- 员工 Token 与管理端 Token 区分(role=employee)
|
||||
- **产出**: `portal.routes.ts` 认证部分 + `portal.service.ts`
|
||||
|
||||
- [x] **T-P7-02** 后端:员工端工资条接口
|
||||
- `GET /api/v1/portal/payslip`:工资条列表(按月)
|
||||
- `GET /api/v1/portal/payslip/:month`:指定月工资明细
|
||||
- `POST /api/v1/portal/payslip/:month/confirm`:确认已阅(记录时间 + IP)
|
||||
- 数据隔离:只能查看自己的工资条
|
||||
- **产出**: 工资条接口
|
||||
|
||||
- [x] **T-P7-03** 后端:员工端合同查看接口
|
||||
- `GET /api/v1/portal/contract`:当前员工的合同信息(只读)
|
||||
- 包含:合同类型、期限、试用期、工资、扫描件/电子链接、签署确认记录
|
||||
- **产出**: 合同查看接口
|
||||
|
||||
- [x] **T-P7-04** 后端:入职填报接口
|
||||
- `GET /api/v1/portal/onboarding/:token`:根据 token 获取填报信息(企业名等)
|
||||
- `POST /api/v1/portal/onboarding/:token`:提交填报数据
|
||||
- Token 校验:有效性 + 过期检查(24h)
|
||||
- 提交后状态 → PENDING,HR 审核后 → APPROVED(创建 Employee)
|
||||
- `POST /api/v1/employees/:id/generate-onboarding-qr`:管理端生成填报 token
|
||||
- **产出**: 入职填报接口 + OnboardingLink 表操作
|
||||
|
||||
- [x] **T-P7-05** 后端:合同确认接口
|
||||
- `GET /api/v1/portal/contract-confirm/:token`:根据 token 获取合同信息
|
||||
- `POST /api/v1/portal/contract-confirm/:token`:确认签署(记录时间 + IP + 设备)
|
||||
- Token 校验:有效性 + 过期检查(7天)
|
||||
- 确认后更新合同状态 + ContractConfirmLink 状态
|
||||
- `POST /api/v1/contracts/:id/generate-confirm-qr`:管理端生成确认 token
|
||||
- **产出**: 合同确认接口 + ContractConfirmLink 表操作
|
||||
|
||||
- [x] **T-P7-06** 后端:二维码生成服务
|
||||
- `services/qrcode.service.ts`
|
||||
- 生成 token + 构建完整 URL(如 `https://xxx/portal/onboarding?token=xxx`)
|
||||
- 返回 URL 供前端生成二维码图片
|
||||
- **产出**: `qrcode.service.ts`
|
||||
|
||||
- [x] **T-P7-07** 前端:员工端登录页面
|
||||
- `/portal/login` 页面
|
||||
- 双 Tab 切换:[密码登录] [验证码登录]
|
||||
- 密码登录:手机号 + 密码
|
||||
- 验证码登录:手机号 → 获取验证码 → 输入验证码
|
||||
- v1.0 验证码页面内弹窗显示
|
||||
- **产出**: `PortalLogin.tsx`
|
||||
|
||||
- [x] **T-P7-08** 前端:员工端工资条页面
|
||||
- `/portal/payslip` 页面
|
||||
- 月份选择器
|
||||
- 工资明细卡片:基本工资 + 加班费拆分(工作日/休息日/节假日)+ 应发合计
|
||||
- 「确认已阅」按钮
|
||||
- 空状态:暂无工资记录
|
||||
- **产出**: `Payslip.tsx`
|
||||
|
||||
- [x] **T-P7-09** 前端:员工端合同查看 + 入职填报 + 合同确认页面
|
||||
- `/portal/contract`:合同信息只读展示 + 扫描件查看 + 签署记录 + 到期提示
|
||||
- `/portal/onboarding`:入职填报表单(姓名/手机号/身份证/银行卡等)+ 提交
|
||||
- `/portal/contract-confirm`:合同信息展示 + 查看合同文件 + 勾选确认 + 签署
|
||||
- Token 失效页面:链接已过期提示
|
||||
- **产出**: `MyContract.tsx` + `Onboarding.tsx` + `ContractConfirm.tsx`
|
||||
|
||||
- [x] **T-P7-10** 前端:管理端二维码生成弹窗
|
||||
- 合同管理页:「生成填报二维码」按钮 → 弹窗显示二维码图片 + 可复制链接
|
||||
- 合同详情页:「生成确认二维码」按钮 → 弹窗显示二维码图片 + 可复制链接
|
||||
- 使用 qrcode.react 生成二维码
|
||||
- 保存二维码图片功能
|
||||
- **产出**: `QRCodeModal.tsx`
|
||||
|
||||
---
|
||||
|
||||
## P8 — 系统设置 + 新手引导 + 空状态(1天)
|
||||
|
||||
- [x] **T-P8-01** 后端:系统设置接口
|
||||
- `GET /api/v1/settings/org`:企业信息
|
||||
- `PUT /api/v1/settings/org`:更新企业信息(名称、城市)
|
||||
- `GET /api/v1/settings/users`:用户列表
|
||||
- `POST /api/v1/settings/users`:添加用户
|
||||
- `PUT /api/v1/settings/users/:id`:编辑用户
|
||||
- `DELETE /api/v1/settings/users/:id`:移除用户
|
||||
- **产出**: `settings.routes.ts` + `settings.service.ts`
|
||||
|
||||
- [x] **T-P8-02** 前端:系统设置页面
|
||||
- `/settings` 页面,3 个子 Tab
|
||||
- 企业信息:名称、城市选择(联动最低工资/社平工资默认值)
|
||||
- 用户管理:用户列表 + 添加/编辑/移除 + 角色分配(admin/hr/viewer)
|
||||
- 套餐信息:当前套餐 + 已用人数 + 上限 + 升级按钮
|
||||
- **产出**: `Settings.tsx`
|
||||
|
||||
- [x] **T-P8-03** 前端:新手引导弹窗
|
||||
- 首次登录显示 3 步引导
|
||||
- Step 1:"这里看风险"(指向首页 Tab)
|
||||
- Step 2:"这里管合同"(指向合同 Tab)
|
||||
- Step 3:"这里算钱"(指向算钱 Tab)
|
||||
- localStorage 记录已看过
|
||||
- **产出**: `OnboardingGuide.tsx`
|
||||
|
||||
- [x] **T-P8-04** 前端:空状态组件
|
||||
- 首页无员工:插图 + 「添加第一个员工」按钮
|
||||
- 合同列表无数据:插图 + 「还没有员工,点这里添加」
|
||||
- 无风险:绿色大勾 + 「✅ 暂无风险,继续保持!」
|
||||
- AI 顾问无对话:欢迎语 + 预设问题
|
||||
- 解聘无历史:插图 + 文字
|
||||
- 员工端无工资条/合同:插图 + 文字
|
||||
- 链接失效:过期提示
|
||||
- **产出**: `EmptyState.tsx` 各场景
|
||||
|
||||
- [x] **T-P8-05** 前端:全局配色 + 样式规范
|
||||
- TailwindCSS 配色:主色 #2563EB、危险 #DC2626、警告 #F59E0B、安全 #16A34A、背景 #F8FAFC
|
||||
- 字体:系统字体栈
|
||||
- 圆角:rounded-lg(卡片)/ rounded-md(按钮)
|
||||
- 阴影:shadow-sm(卡片)
|
||||
- **产出**: `tailwind.config.ts` 完整配置
|
||||
|
||||
---
|
||||
|
||||
## P9 — 移动端适配 + 联调(2天)
|
||||
|
||||
- [x] **T-P9-01** 前端:响应式适配
|
||||
- 桌面 ≥1280px:顶部导航 + 960px 居中
|
||||
- 平板 768-1279px:顶部导航 + 全宽
|
||||
- 手机 375-767px:底部 Tab Bar + 全宽
|
||||
- 合同列表 → 移动端卡片式
|
||||
- 计算器 → 移动端上下排列
|
||||
- AI 聊天 → 移动端全屏
|
||||
- 员工端 → 移动端优先(员工主要用手机)
|
||||
- **产出**: 响应式样式
|
||||
|
||||
- [x] **T-P9-02** 前端:员工端移动端优化
|
||||
- 员工端以移动端为主场景
|
||||
- 大按钮、大字体、简洁布局
|
||||
- 扫码后自动适配手机屏幕
|
||||
- 工资条卡片式展示
|
||||
- 合同信息折叠展开
|
||||
- **产出**: 员工端移动端样式
|
||||
|
||||
- [x] **T-P9-03** 全栈:端到端联调
|
||||
- 注册 → 登录 → 添加员工 → 查看首页 → 合同管理 → 计算 → 解聘 → AI 问答 → 员工端登录 → 工资条 → 入职填报 → 合同确认
|
||||
- 多租户隔离测试:A 企业无法访问 B 企业数据
|
||||
- 员工端隔离测试:员工只能查看自己的数据
|
||||
- Token 过期自动刷新测试
|
||||
- **产出**: 联调问题清单 + 修复
|
||||
|
||||
- [x] **T-P9-04** 全栈:性能优化
|
||||
- 前端:路由懒加载(React.lazy + Suspense)
|
||||
- 前端:API 请求缓存(React Query staleTime 配置)
|
||||
- 后端:数据库索引(orgId + 常用查询字段)
|
||||
- 后端:API 响应压缩(compression 中间件)
|
||||
- **产出**: 性能优化
|
||||
|
||||
---
|
||||
|
||||
## P10 — 部署上线 + 验证(1天)
|
||||
|
||||
- [x] **T-P10-01** 后端:部署配置(`netlify.toml` + `.env.example`)
|
||||
- 配置 Railway 项目
|
||||
- 环境变量配置:DATABASE_URL, JWT_SECRET, DASHSCOPE_API_KEY, ENCRYPTION_KEY, SUPABASE_URL, CORS_ORIGIN
|
||||
- 运行 Prisma migrate deploy
|
||||
- 健康检查验证
|
||||
- **产出**: 后端线上地址
|
||||
|
||||
- [x] **T-P10-02** 前端:部署到 Netlify(`netlify.toml` 已配置)
|
||||
- 配置 Vercel 项目
|
||||
- 环境变量配置:VITE_API_URL
|
||||
- 构建配置:`npm run build`
|
||||
- 路由重写配置(SPA fallback)
|
||||
- **产出**: 前端线上地址
|
||||
|
||||
- [ ] **T-P10-03** 数据库:Neon/Supabase 配置
|
||||
- 创建 PostgreSQL 数据库
|
||||
- 启用 pgvector 扩展(AI 模块用)
|
||||
- 配置连接池
|
||||
- 运行迁移
|
||||
- 导入 RAG 知识库数据
|
||||
- **产出**: 数据库线上环境
|
||||
|
||||
- [ ] **T-P10-04** 验收测试
|
||||
- 按 1-prd.md 第 10 章验收标准逐项验证
|
||||
- 功能验收:注册/登录/首页/合同/计算/解聘/AI/员工端/设置
|
||||
- 非功能验收:性能/安全/响应式/兼容/数据隔离
|
||||
- 修复发现的问题
|
||||
- **产出**: 验收报告
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```
|
||||
P0 ──→ P1 ──→ P2 ──→ P3 ──→ P4(纯前端,可与 P3 并行)
|
||||
│
|
||||
├──→ P5(依赖 P3 员工数据 + P4 补偿金计算)
|
||||
│
|
||||
├──→ P6(依赖 P0 数据库 + P2 风险数据)
|
||||
│
|
||||
├──→ P7(依赖 P3 合同数据 + P0 数据库)
|
||||
│
|
||||
└──→ P8(依赖 P1 认证)
|
||||
|
||||
P9(依赖 P2~P8 全部完成)
|
||||
P10(依赖 P9 完成)
|
||||
```
|
||||
|
||||
**可并行任务**:
|
||||
- P4(钱的计算)纯前端计算,可在 P3 完成后与 P5/P6 并行
|
||||
- P8(系统设置)可在 P6/P7 期间并行
|
||||
|
||||
---
|
||||
|
||||
## 技术栈速查
|
||||
|
||||
| 层 | 技术 |
|
||||
|-----|------|
|
||||
| 前端框架 | React 18 + Vite + TypeScript |
|
||||
| 前端样式 | TailwindCSS |
|
||||
| 前端路由 | React Router v6 |
|
||||
| 状态管理 | Zustand(认证)+ TanStack Query(服务端数据)|
|
||||
| 表单 | React Hook Form + Zod |
|
||||
| 二维码 | qrcode.react |
|
||||
| 图标 | lucide-react |
|
||||
| 后端框架 | Express + TypeScript |
|
||||
| ORM | Prisma |
|
||||
| 数据库 | PostgreSQL(Neon/Supabase)+ pgvector |
|
||||
| 认证 | JWT(Access + Refresh)|
|
||||
| 加密 | bcrypt(密码)+ AES-256(工资)|
|
||||
| AI | 通义千问 Qwen(DashScope API)|
|
||||
| Embedding | DashScope text-embedding-v2 |
|
||||
| 部署 | Vercel(前端)+ Railway(后端)|
|
||||
|
||||
---
|
||||
|
||||
## 环境变量清单
|
||||
|
||||
```env
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://...
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=...
|
||||
JWT_REFRESH_SECRET=...
|
||||
|
||||
# DashScope (通义千问)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
|
||||
# 加密
|
||||
ENCRYPTION_KEY=...
|
||||
|
||||
# 存储
|
||||
SUPABASE_URL=...
|
||||
SUPABASE_KEY=...
|
||||
|
||||
# 部署
|
||||
PORT=3000
|
||||
CORS_ORIGIN=https://your-app.vercel.app
|
||||
|
||||
# 前端
|
||||
VITE_API_URL=https://your-backend.railway.app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P11 — 功能补齐(3天)
|
||||
|
||||
> **目标**: 补齐中小企业 HR 实际使用中的关键缺失功能
|
||||
|
||||
### 后端
|
||||
|
||||
- [x] **T-P11-01** 后端:社保公积金计算器
|
||||
- Prisma 模型 `SocialInsuranceConfig`(养老/医疗/失业/工伤/生育/公积金 比例 + 基数上下限)
|
||||
- `social.routes.ts`:GET/PUT 配置 + POST 计算
|
||||
- 支持基数封顶/保底逻辑
|
||||
- **产出**: `social.routes.ts` + `SocialInsuranceConfig` 模型
|
||||
|
||||
- [x] **T-P11-02** 后端:到期提醒通知服务
|
||||
- Prisma 模型 `NotificationSetting`(通知开关 + 提前天数 + 微信Webhook + 邮箱)
|
||||
- Prisma 模型 `NotificationLog`(通知记录)
|
||||
- `notification.routes.ts`:GET/PUT 设置 + GET 日志 + POST 手动检查
|
||||
- 支持企业微信 Webhook 推送
|
||||
- **产出**: `notification.routes.ts` + `NotificationSetting` + `NotificationLog` 模型
|
||||
|
||||
- [x] **T-P11-03** 后端:批量生成工资条
|
||||
- `POST /api/v1/payroll/payslip/batch-generate`
|
||||
- 自动遍历所有在职员工,关联加班记录,一键生成全员工资条
|
||||
- 支持传入津贴/扣款映射
|
||||
- **产出**: `payroll.routes.ts` 新增接口
|
||||
|
||||
- [x] **T-P11-04** 后端:Excel/CSV 批量导入加班数据
|
||||
- `POST /api/v1/payroll/overtime/batch`
|
||||
- 接收数组格式加班数据,批量 upsert
|
||||
- 前端解析 CSV 按员工姓名匹配
|
||||
- **产出**: `payroll.routes.ts` 新增接口
|
||||
|
||||
- [x] **T-P11-05** 后端:员工档案附件管理
|
||||
- Prisma 模型 `EmployeeAttachment`(文件名/类型/URL/大小)
|
||||
- `attachment.routes.ts`:GET 列表 + POST 添加 + DELETE 删除
|
||||
- 支持身份证/银行卡/合同扫描件/学历证书/其他分类
|
||||
- **产出**: `attachment.routes.ts` + `EmployeeAttachment` 模型
|
||||
|
||||
### 前端
|
||||
|
||||
- [x] **T-P11-06** 前端:社保公积金计算器 Tab
|
||||
- Money 页面新增「社保公积金」Tab
|
||||
- `SocialInsuranceCalculator` 组件:输入缴费基数 → 计算五险一金明细
|
||||
- 支持企业/个人比例配置(可展开配置面板)
|
||||
- 表格展示各险种比例、企业缴纳、个人缴纳
|
||||
- **产出**: `Money.tsx` 新增 `SocialInsuranceCalculator` 组件
|
||||
|
||||
- [x] **T-P11-07** 前端:批量生成工资条 UI
|
||||
- PayslipManager 新增「一键全员生成」按钮
|
||||
- 调用 `batch-generate` 接口,自动关联加班费
|
||||
- **产出**: `Money.tsx` PayslipManager 增强
|
||||
|
||||
- [x] **T-P11-08** 前端:CSV 批量导入加班数据
|
||||
- OvertimeCalculator 新增「批量导入加班数据(CSV)」按钮
|
||||
- 前端解析 CSV(姓名,工作日加班,休息日加班,节假日加班,月份)
|
||||
- 按员工姓名自动匹配 employeeId
|
||||
- **产出**: `Money.tsx` OvertimeCalculator 增强
|
||||
|
||||
- [x] **T-P11-09** 前端:员工档案附件管理 UI
|
||||
- Contracts 页面点击员工行打开右侧抽屉
|
||||
- `EmployeeDetailDrawer` 组件:展示员工基本信息 + 合同信息 + 附件管理
|
||||
- 支持文件上传(FileReader → base64)和删除
|
||||
- 附件分类:身份证/银行卡/合同扫描件/学历证书/其他
|
||||
- **产出**: `Contracts.tsx` 新增 `EmployeeDetailDrawer` 组件
|
||||
|
||||
- [x] **T-P11-10** 前端:通知设置页面
|
||||
- Settings 页面新增「通知设置」Tab
|
||||
- `NotificationSettings` 组件:合同到期提醒/未签提醒/加班超时/工资条通知开关
|
||||
- 提前提醒天数配置
|
||||
- 企业微信 Webhook 配置
|
||||
- 邮件通知配置
|
||||
- 手动触发合同到期检查 + 通知日志展示
|
||||
- **产出**: `Settings.tsx` 新增 `NotificationSettings` 组件
|
||||
@@ -0,0 +1,21 @@
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/hr_compliance?schema=public
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-jwt-secret-change-in-production
|
||||
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production
|
||||
|
||||
# DashScope (通义千问)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
# 加密
|
||||
ENCRYPTION_KEY=your-32-byte-encryption-key-here
|
||||
|
||||
# 存储
|
||||
SUPABASE_URL=
|
||||
SUPABASE_KEY=
|
||||
|
||||
# 部署
|
||||
PORT=3000
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
Generated
+2725
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "hr-compliance-backend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.18.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.0",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"openai": "^6.48.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^5.18.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ========== 枚举 ==========
|
||||
|
||||
enum Plan {
|
||||
FREE
|
||||
PRO
|
||||
ENTERPRISE
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
HR
|
||||
VIEWER
|
||||
}
|
||||
|
||||
enum EmployeeStatus {
|
||||
ACTIVE
|
||||
RESIGNED
|
||||
}
|
||||
|
||||
enum ContractType {
|
||||
FIXED
|
||||
UNFIXED
|
||||
UNSIGNED
|
||||
}
|
||||
|
||||
enum SignMethod {
|
||||
PAPER
|
||||
ELECTRONIC
|
||||
}
|
||||
|
||||
enum RiskType {
|
||||
CONTRACT
|
||||
SALARY
|
||||
TERMINATION
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
enum RiskLevel {
|
||||
HIGH
|
||||
MEDIUM
|
||||
LOW
|
||||
}
|
||||
|
||||
enum RiskStatus {
|
||||
PENDING
|
||||
RESOLVED
|
||||
IGNORED
|
||||
}
|
||||
|
||||
enum TerminationReason {
|
||||
NEGOTIATED
|
||||
FAULT
|
||||
NONFAULT
|
||||
LAYOFF
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum RiskAssessment {
|
||||
SAFE
|
||||
WARNING
|
||||
DANGER
|
||||
}
|
||||
|
||||
enum OnboardingStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ContractConfirmStatus {
|
||||
UNCONFIRMED
|
||||
CONFIRMED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
// ========== 核心表 ==========
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
plan Plan @default(FREE)
|
||||
maxEmployees Int @default(20)
|
||||
city String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
employees Employee[]
|
||||
contracts LaborContract[]
|
||||
overtimeRecords OvertimeRecord[]
|
||||
terminations TerminationRecord[]
|
||||
riskItems RiskItem[]
|
||||
auditLogs AuditLog[]
|
||||
payslips Payslip[]
|
||||
onboardingLinks OnboardingLink[]
|
||||
confirmLinks ContractConfirmLink[]
|
||||
socialInsuranceConfig SocialInsuranceConfig?
|
||||
notificationSetting NotificationSetting?
|
||||
notificationLogs NotificationLog[]
|
||||
employeeAttachments EmployeeAttachment[]
|
||||
disciplinaryRecords DisciplinaryRecord[]
|
||||
attendanceRecords AttendanceRecord[]
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
phone String @unique
|
||||
email String?
|
||||
passwordHash String
|
||||
name String
|
||||
role Role @default(ADMIN)
|
||||
createdAt DateTime @default(now())
|
||||
lastLoginAt DateTime?
|
||||
}
|
||||
|
||||
// ========== 业务表 ==========
|
||||
|
||||
model Employee {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
department String
|
||||
hireDate DateTime
|
||||
monthlySalary String // AES-256 加密存储
|
||||
status EmployeeStatus @default(ACTIVE)
|
||||
gender String?
|
||||
phone String?
|
||||
idCardNumber String? // AES-256 加密存储
|
||||
emergencyContact String?
|
||||
emergencyPhone String?
|
||||
address String?
|
||||
bankAccount String? // AES-256 加密存储
|
||||
bankName String?
|
||||
passwordHash String? // 员工端登录密码
|
||||
isPregnant Boolean @default(false)
|
||||
isInMedicalPeriod Boolean @default(false)
|
||||
isWorkInjured Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
contracts LaborContract[]
|
||||
overtimeRecords OvertimeRecord[]
|
||||
terminations TerminationRecord[]
|
||||
riskItems RiskItem[]
|
||||
payslips Payslip[]
|
||||
attachments EmployeeAttachment[]
|
||||
disciplinaryRecords DisciplinaryRecord[]
|
||||
attendanceRecords AttendanceRecord[]
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
}
|
||||
|
||||
model LaborContract {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
signDate DateTime?
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
contractType ContractType
|
||||
signMethod SignMethod @default(PAPER)
|
||||
contractYears Int @default(3)
|
||||
probationMonths Int @default(0)
|
||||
probationSalary Int @default(0)
|
||||
renewalCount Int @default(0)
|
||||
attachmentName String?
|
||||
attachmentUrl String?
|
||||
electronicContractNo String?
|
||||
electronicContractUrl String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
confirmLinks ContractConfirmLink[]
|
||||
}
|
||||
|
||||
model OvertimeRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
month String // YYYY-MM
|
||||
weekdayHours Float @default(0)
|
||||
weekendHours Float @default(0)
|
||||
holidayHours Float @default(0)
|
||||
weekdayPay Float @default(0)
|
||||
weekendPay Float @default(0)
|
||||
holidayPay Float @default(0)
|
||||
totalPay Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([employeeId, month])
|
||||
}
|
||||
|
||||
model TerminationRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
reason TerminationReason
|
||||
terminationDate DateTime
|
||||
compensation Float @default(0)
|
||||
riskLevel RiskAssessment @default(SAFE)
|
||||
checklist Json
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model RiskItem {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String?
|
||||
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
|
||||
type RiskType
|
||||
level RiskLevel
|
||||
status RiskStatus @default(PENDING)
|
||||
title String
|
||||
description String
|
||||
actionUrl String?
|
||||
resolvedAt DateTime?
|
||||
resolvedBy String?
|
||||
remark String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([orgId, type])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
action String
|
||||
entity String
|
||||
entityId String?
|
||||
detail Json?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, createdAt])
|
||||
}
|
||||
|
||||
// ========== 社保 & 通知 & 附件 ==========
|
||||
|
||||
model SocialInsuranceConfig {
|
||||
id String @id @default(cuid())
|
||||
orgId String @unique
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京")
|
||||
pensionOrg Float @default(16) // 养老保险 企业比例 %
|
||||
pensionEmp Float @default(8) // 养老保险 个人比例 %
|
||||
medicalOrg Float @default(9.8) // 医疗保险 企业比例 %
|
||||
medicalEmp Float @default(2) // 医疗保险 个人比例 %
|
||||
unemploymentOrg Float @default(0.5) // 失业保险 企业比例 %
|
||||
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
|
||||
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
|
||||
maternityOrg Float @default(0.8) // 生育保险 企业比例 %
|
||||
housingOrg Float @default(12) // 公积金 企业比例 %
|
||||
housingEmp Float @default(12) // 公积金 个人比例 %
|
||||
baseMin Float @default(6326) // 缴费基数下限
|
||||
baseMax Float @default(33891) // 缴费基数上限
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model NotificationSetting {
|
||||
id String @id @default(cuid())
|
||||
orgId String @unique
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
contractExpiry Boolean @default(true)
|
||||
expiryDays Int @default(30)
|
||||
contractUnsigned Boolean @default(true)
|
||||
overtimeAlert Boolean @default(true)
|
||||
payslipReady Boolean @default(true)
|
||||
// 月度事务提醒日(每月几号)
|
||||
payrollDay Int @default(10) // 发薪日
|
||||
socialInsDay Int @default(15) // 社保缴纳日
|
||||
housingFundDay Int @default(15) // 公积金缴纳日
|
||||
taxDay Int @default(15) // 个税申报日
|
||||
wechatWebhook String?
|
||||
emailNotify Boolean @default(false)
|
||||
email String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model NotificationLog {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // CONTRACT_EXPIRY / CONTRACT_UNSIGNED / OVERTIME / PAYSLIP
|
||||
title String
|
||||
content String
|
||||
channel String // WECHAT / EMAIL / IN_APP
|
||||
status String @default("SENT") // SENT / FAILED
|
||||
employeeId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, createdAt])
|
||||
}
|
||||
|
||||
model EmployeeAttachment {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
fileName String
|
||||
fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / OTHER
|
||||
fileUrl String
|
||||
fileSize Int @default(0)
|
||||
uploadedBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
// ========== 仲裁证据链 ==========
|
||||
|
||||
model DisciplinaryRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
violationDate DateTime
|
||||
violationType String // LATE/ABSENT/INSUBORDINATION/MISCONDUCT/VIOLATE_POLICY/OTHER
|
||||
description String
|
||||
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
||||
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
||||
actionDetail String?
|
||||
employeeAck Boolean @default(false) // 员工是否签字确认
|
||||
ackDate DateTime?
|
||||
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
||||
witness String? // 见证人
|
||||
attachmentUrl String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
model AttendanceRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
date DateTime
|
||||
checkInTime String? // HH:mm
|
||||
checkOutTime String? // HH:mm
|
||||
status String @default("NORMAL") // NORMAL/LATE/EARLY_LEAVE/ABSENT/LEAVE/BUSINESS_TRIP
|
||||
lateMinutes Int @default(0)
|
||||
earlyMinutes Int @default(0)
|
||||
workHours Float @default(0)
|
||||
overtimeHours Float @default(0)
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([employeeId, date])
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
model TrainingRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
trainingDate DateTime
|
||||
topic String // 培训主题/制度名称
|
||||
content String? // 培训内容摘要
|
||||
trainer String?
|
||||
duration Float @default(0) // 培训时长(小时)
|
||||
ackStatus String @default("PENDING") // PENDING/SIGNED/REFUSED
|
||||
ackDate DateTime?
|
||||
attachmentUrl String? // 签收单扫描件
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
model PerformanceRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
period String // 考核周期 YYYY-MM 或 YYYY-Q1
|
||||
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? // 改进计划(不胜任时)
|
||||
employeeAck Boolean @default(false)
|
||||
ackDate DateTime?
|
||||
reviewer String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([employeeId, period])
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
// ========== 员工端表 ==========
|
||||
|
||||
model Payslip {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
month String // YYYY-MM
|
||||
baseSalary Float @default(0)
|
||||
overtimePay Float @default(0)
|
||||
weekdayOvertimePay Float @default(0)
|
||||
weekendOvertimePay Float @default(0)
|
||||
holidayOvertimePay Float @default(0)
|
||||
allowance Float @default(0)
|
||||
deduction Float @default(0)
|
||||
totalPay Float @default(0)
|
||||
confirmedAt DateTime?
|
||||
confirmedIp String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([employeeId, month])
|
||||
@@index([orgId, month])
|
||||
}
|
||||
|
||||
model OnboardingLink {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
employeeName String?
|
||||
phone String?
|
||||
status OnboardingStatus @default(PENDING)
|
||||
formData Json?
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
}
|
||||
|
||||
model ContractConfirmLink {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
contractId String
|
||||
contract LaborContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
status ContractConfirmStatus @default(UNCONFIRMED)
|
||||
confirmedAt DateTime?
|
||||
confirmedIp String?
|
||||
expiresAt DateTime
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// 创建测试企业
|
||||
let org = await prisma.organization.findFirst({ where: { name: '测试科技有限公司' } })
|
||||
if (!org) {
|
||||
org = await prisma.organization.create({
|
||||
data: {
|
||||
name: '测试科技有限公司',
|
||||
plan: 'FREE',
|
||||
maxEmployees: 20,
|
||||
city: '上海',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 创建管理员用户
|
||||
const passwordHash = await bcrypt.hash('12345678', 10)
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { phone: '13800000001' },
|
||||
update: {},
|
||||
create: {
|
||||
orgId: org.id,
|
||||
phone: '13800000001',
|
||||
name: '管理员',
|
||||
passwordHash,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建测试员工
|
||||
const salaryHash = randomBytes(32).toString('hex')
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
name: '张三',
|
||||
department: '技术部',
|
||||
hireDate: new Date('2026-01-15'),
|
||||
monthlySalary: 'encrypted:' + salaryHash,
|
||||
phone: '13900000001',
|
||||
gender: '男',
|
||||
createdBy: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建测试合同
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
employeeId: employee.id,
|
||||
signDate: new Date('2026-01-20'),
|
||||
startDate: new Date('2026-02-01'),
|
||||
endDate: new Date('2029-01-31'),
|
||||
contractType: 'FIXED',
|
||||
signMethod: 'PAPER',
|
||||
contractYears: 3,
|
||||
probationMonths: 2,
|
||||
probationSalary: 6400,
|
||||
createdBy: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Seed data created:', { org: org.id, admin: admin.id, employee: employee.id })
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import compression from 'compression'
|
||||
import { errorHandler } from './middleware/errorHandler'
|
||||
import { apiLimiter } from './middleware/rateLimit'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(helmet())
|
||||
app.use(compression())
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
app.use(express.json())
|
||||
app.use(morgan('dev'))
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
|
||||
})
|
||||
|
||||
app.use('/api/v1', apiLimiter)
|
||||
|
||||
// 路由挂载
|
||||
import authRoutes from './routes/auth.routes'
|
||||
import dashboardRoutes from './routes/dashboard.routes'
|
||||
import employeeRoutes from './routes/employee.routes'
|
||||
import terminationRoutes from './routes/termination.routes'
|
||||
import aiRoutes from './routes/ai.routes'
|
||||
import portalRoutes from './routes/portal.routes'
|
||||
import settingsRoutes from './routes/settings.routes'
|
||||
import payrollRoutes from './routes/payroll.routes'
|
||||
import socialRoutes from './routes/social.routes'
|
||||
import notificationRoutes from './routes/notification.routes'
|
||||
import attachmentRoutes from './routes/attachment.routes'
|
||||
import rosterRoutes from './routes/roster.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
app.use('/api/v1/termination', terminationRoutes)
|
||||
app.use('/api/v1/ai', aiRoutes)
|
||||
app.use('/api/v1/portal', portalRoutes)
|
||||
app.use('/api/v1/settings', settingsRoutes)
|
||||
app.use('/api/v1/payroll', payrollRoutes)
|
||||
app.use('/api/v1/social', socialRoutes)
|
||||
app.use('/api/v1/notifications', notificationRoutes)
|
||||
app.use('/api/v1/attachments', attachmentRoutes)
|
||||
app.use('/api/v1/roster', rosterRoutes)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,7 @@
|
||||
import app from './app'
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-32-byte-encryption-key!!'
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const KEY = Buffer.from(ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32), 'utf8')
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return iv.toString('hex') + ':' + encrypted
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const [ivHex, encrypted] = encryptedText.split(':')
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret'
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret'
|
||||
|
||||
export function signAccessToken(payload: { id: string; orgId: string; role: string }): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '2h' })
|
||||
}
|
||||
|
||||
export function signRefreshToken(payload: { id: string; orgId: string; role: string }): string {
|
||||
return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: '7d' })
|
||||
}
|
||||
|
||||
export function verifyAccessToken(token: string): { id: string; orgId: string; role: string } | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as { id: string; orgId: string; role: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(token: string): { id: string; orgId: string; role: string } | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET) as { id: string; orgId: string; role: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export default prisma
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AuthRequest } from './auth'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
export async function auditLog(
|
||||
req: AuthRequest,
|
||||
action: string,
|
||||
entity: string,
|
||||
entityId?: string,
|
||||
detail?: Record<string, unknown>,
|
||||
) {
|
||||
if (!req.user) return
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orgId: req.user.orgId,
|
||||
userId: req.user.id,
|
||||
action,
|
||||
entity,
|
||||
entityId,
|
||||
detail: detail ? JSON.parse(JSON.stringify(detail)) : undefined,
|
||||
ip: req.ip,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Audit log error:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { verifyAccessToken } from '../lib/jwt'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: { id: string; orgId: string; role: string }
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
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: '令牌无效或已过期' } })
|
||||
}
|
||||
req.user = payload
|
||||
next()
|
||||
}
|
||||
|
||||
export function orgFilterMiddleware(req: AuthRequest, _res: Response, next: NextFunction) {
|
||||
if (req.user) {
|
||||
req.orgId = req.user.orgId
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { ZodError } from 'zod'
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
|
||||
|
||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
||||
if (err instanceof ZodError) {
|
||||
return res.status(422).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '输入校验失败',
|
||||
details: err.errors.map((e) => ({ path: e.path.join('.'), message: e.message })),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (err instanceof PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { code: 'DUPLICATE', message: '数据已存在,请勿重复操作' },
|
||||
})
|
||||
}
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: '记录不存在' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
message: { success: false, error: { code: 'RATE_LIMIT', message: '操作过于频繁,请稍后再试' } },
|
||||
})
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 5,
|
||||
message: { success: false, error: { code: 'RATE_LIMIT', message: '登录尝试过于频繁,请稍后再试' } },
|
||||
})
|
||||
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
message: { success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请稍后再试' } },
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { chat, reviewContract, matchCase, predictRisks } from '../services/ai.service'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
const router = Router()
|
||||
|
||||
async function buildOrgContext(orgId: string): Promise<string> {
|
||||
const [employees, risks] = await Promise.all([
|
||||
prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
}),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
include: { employee: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const empSummary = employees.map((e) => {
|
||||
const contract = e.contracts[0]
|
||||
return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同类型:${contract.contractType}` : '未签合同'}`
|
||||
}).join('\n')
|
||||
|
||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level})`).join('\n')
|
||||
|
||||
return `员工列表(${employees.length}人):
|
||||
${empSummary}
|
||||
|
||||
当前风险项(${risks.length}项):
|
||||
${riskSummary}`
|
||||
}
|
||||
|
||||
router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
|
||||
}
|
||||
const orgContext = await buildOrgContext(req.user!.orgId)
|
||||
const reply = await chat(messages, orgContext)
|
||||
res.json({ success: true, data: { reply } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { contractText } = req.body as { contractText: string }
|
||||
if (!contractText) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } })
|
||||
}
|
||||
const result = await reviewContract(contractText)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { scenario } = req.body as { scenario: string }
|
||||
if (!scenario) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } })
|
||||
}
|
||||
const result = await matchCase(scenario)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgContext = await buildOrgContext(req.user!.orgId)
|
||||
const result = await predictRisks(orgContext)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取员工附件列表
|
||||
router.get('/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const attachments = await prisma.employeeAttachment.findMany({
|
||||
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: attachments })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加附件记录(文件URL由前端上传后传入)
|
||||
const attachmentSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
fileName: z.string().min(1),
|
||||
fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'OTHER']),
|
||||
fileUrl: z.string().min(1),
|
||||
fileSize: z.number().int().default(0),
|
||||
})
|
||||
|
||||
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = attachmentSchema.parse(req.body)
|
||||
const attachment = await prisma.employeeAttachment.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
...data,
|
||||
uploadedBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: attachment })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除附件
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const attachment = await prisma.employeeAttachment.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '附件不存在' } })
|
||||
}
|
||||
await prisma.employeeAttachment.delete({ where: { id: attachment.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express'
|
||||
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema } from '../schemas/auth.schema'
|
||||
import { register, login, refresh, resetPassword } from '../services/auth.service'
|
||||
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/register', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = registerSchema.parse(req.body)
|
||||
const result = await register(data.orgName, data.phone, data.password)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/login', loginLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = loginSchema.parse(req.body)
|
||||
const result = await login(data.phone, data.password)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const data = refreshSchema.parse(req.body)
|
||||
const result = await refresh(data.refreshToken)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reset-password', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = resetPasswordSchema.parse(req.body)
|
||||
const result = await resetPassword(data.phone, data.newPassword)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { getDashboardData } from '../services/risk.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = await getDashboardData(req.user!.orgId)
|
||||
res.json({ success: true, data })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 标记待办为已完成
|
||||
router.patch('/todos/:id/resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await prisma.riskItem.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
if (item.count === 0) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
|
||||
}
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 忽略待办
|
||||
router.patch('/todos/:id/ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await prisma.riskItem.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
if (item.count === 0) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
|
||||
}
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import {
|
||||
createEmployeeSchema,
|
||||
updateEmployeeSchema,
|
||||
batchRenewSchema,
|
||||
addContractSchema,
|
||||
} from '../schemas/contract.schema'
|
||||
import {
|
||||
getEmployees,
|
||||
getEmployeeDetail,
|
||||
createEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
batchRenew,
|
||||
addContract,
|
||||
} from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await getEmployees(req.user!.orgId, {
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
pageSize: parseInt(req.query.pageSize as string) || 20,
|
||||
search: req.query.search as string,
|
||||
department: req.query.department as string,
|
||||
})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await getEmployeeDetail(req.user!.orgId, req.params.id)
|
||||
res.json({ success: true, data: employee })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createEmployeeSchema.parse(req.body)
|
||||
const result = await createEmployee(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'CREATE', 'EMPLOYEE', result.id, { name: data.name })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = updateEmployeeSchema.parse(req.body)
|
||||
const result = await updateEmployee(req.user!.orgId, req.params.id, data)
|
||||
await auditLog(req, 'UPDATE', 'EMPLOYEE', req.params.id, data)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await deleteEmployee(req.user!.orgId, req.params.id)
|
||||
await auditLog(req, 'DELETE', 'EMPLOYEE', req.params.id)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/contracts/batch-renew', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = batchRenewSchema.parse(req.body)
|
||||
const result = await batchRenew(req.user!.orgId, req.user!.id, data.contractIds, data.years)
|
||||
await auditLog(req, 'BATCH_RENEW', 'CONTRACT', undefined, { count: data.contractIds.length })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = addContractSchema.parse(req.body)
|
||||
const result = await addContract(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'ADD_CONTRACT', 'CONTRACT', result.id, { employeeId: data.employeeId })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取通知设置
|
||||
router.get('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let setting = await prisma.notificationSetting.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!setting) {
|
||||
setting = await prisma.notificationSetting.create({
|
||||
data: { orgId: req.user!.orgId },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: setting })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新通知设置
|
||||
const settingSchema = z.object({
|
||||
contractExpiry: z.boolean().optional(),
|
||||
expiryDays: z.number().int().min(1).max(365).optional(),
|
||||
contractUnsigned: z.boolean().optional(),
|
||||
overtimeAlert: z.boolean().optional(),
|
||||
payslipReady: z.boolean().optional(),
|
||||
payrollDay: z.number().int().min(1).max(28).optional(),
|
||||
socialInsDay: z.number().int().min(1).max(28).optional(),
|
||||
housingFundDay: z.number().int().min(1).max(28).optional(),
|
||||
taxDay: z.number().int().min(1).max(28).optional(),
|
||||
wechatWebhook: z.string().url().nullable().optional(),
|
||||
emailNotify: z.boolean().optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
})
|
||||
|
||||
router.put('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = settingSchema.parse(req.body)
|
||||
const setting = await prisma.notificationSetting.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
})
|
||||
res.json({ success: true, data: setting })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取通知列表
|
||||
router.get('/logs', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.notificationLog.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.notificationLog.count({ where: { orgId: req.user!.orgId } }),
|
||||
])
|
||||
res.json({ success: true, data: { items: logs, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发合同到期检查
|
||||
router.post('/check-contracts', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const setting = await prisma.notificationSetting.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
const expiryDays = setting?.expiryDays || 30
|
||||
const now = new Date()
|
||||
const threshold = new Date(now.getTime() + expiryDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
const contracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
endDate: { lte: threshold, gte: now },
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
})
|
||||
|
||||
const logs: any[] = []
|
||||
for (const contract of contracts) {
|
||||
const daysLeft = Math.ceil((contract.endDate!.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const title = `${contract.employee.name}的合同将在${daysLeft}天后到期`
|
||||
const content = `员工 ${contract.employee.name}(${contract.employee.department})的合同将于 ${contract.endDate!.toISOString().slice(0, 10)} 到期,请及时处理续签或终止事宜。`
|
||||
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
type: 'CONTRACT_EXPIRY',
|
||||
title,
|
||||
content,
|
||||
channel: 'IN_APP',
|
||||
employeeId: contract.employeeId,
|
||||
},
|
||||
})
|
||||
logs.push(log)
|
||||
|
||||
if (setting?.wechatWebhook) {
|
||||
try {
|
||||
await fetch(setting.wechatWebhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'text',
|
||||
text: { content: `【合同到期提醒】${title}\n${content}` },
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
// webhook 发送失败不阻断流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { checked: contracts.length, notified: logs.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,338 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// ========== 加班费记录 ==========
|
||||
|
||||
const overtimeSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
monthlyWage: z.number().positive(),
|
||||
weekdayHours: z.number().min(0).default(0),
|
||||
weekendHours: z.number().min(0).default(0),
|
||||
holidayHours: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
// 获取加班费记录列表
|
||||
router.get('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId, month } = req.query
|
||||
const records = await prisma.overtimeRecord.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
...(employeeId ? { employeeId: String(employeeId) } : {}),
|
||||
...(month ? { month: String(month) } : {}),
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 保存加班费记录
|
||||
router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = overtimeSchema.parse(req.body)
|
||||
const hourlyWage = data.monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * data.weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * data.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
where: {
|
||||
employeeId_month: { employeeId: data.employeeId, month: data.month },
|
||||
},
|
||||
update: {
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 工资条管理 ==========
|
||||
|
||||
const payslipSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
baseSalary: z.number().min(0).default(0),
|
||||
overtimePay: z.number().min(0).default(0),
|
||||
weekdayOvertimePay: z.number().min(0).default(0),
|
||||
weekendOvertimePay: z.number().min(0).default(0),
|
||||
holidayOvertimePay: z.number().min(0).default(0),
|
||||
allowance: z.number().min(0).default(0),
|
||||
deduction: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
// 获取工资条列表
|
||||
router.get('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, employeeId } = req.query
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
...(month ? { month: String(month) } : {}),
|
||||
...(employeeId ? { employeeId: String(employeeId) } : {}),
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: [{ month: 'desc' }, { employee: { name: 'asc' } }],
|
||||
})
|
||||
res.json({ success: true, data: payslips })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建/更新工资条
|
||||
router.post('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = payslipSchema.parse(req.body)
|
||||
const totalPay = data.baseSalary + data.overtimePay + data.allowance - data.deduction
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: {
|
||||
employeeId_month: { employeeId: data.employeeId, month: data.month },
|
||||
},
|
||||
update: {
|
||||
baseSalary: data.baseSalary,
|
||||
overtimePay: data.overtimePay,
|
||||
weekdayOvertimePay: data.weekdayOvertimePay,
|
||||
weekendOvertimePay: data.weekendOvertimePay,
|
||||
holidayOvertimePay: data.holidayOvertimePay,
|
||||
allowance: data.allowance,
|
||||
deduction: data.deduction,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
baseSalary: data.baseSalary,
|
||||
overtimePay: data.overtimePay,
|
||||
weekdayOvertimePay: data.weekdayOvertimePay,
|
||||
weekendOvertimePay: data.weekendOvertimePay,
|
||||
holidayOvertimePay: data.holidayOvertimePay,
|
||||
allowance: data.allowance,
|
||||
deduction: data.deduction,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 从加班费记录自动生成工资条
|
||||
router.post('/payslip/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, employeeId, baseSalary, allowance, deduction } = req.body as {
|
||||
month: string
|
||||
employeeId: string
|
||||
baseSalary: number
|
||||
allowance?: number
|
||||
deduction?: number
|
||||
}
|
||||
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const totalPay = baseSalary + overtimePay + (allowance || 0) - (deduction || 0)
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
update: {
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId,
|
||||
month,
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除工资条
|
||||
router.delete('/payslip/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await prisma.payslip.delete({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量生成工资条 ==========
|
||||
|
||||
const batchGenerateSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
allowances: z.record(z.string(), z.number().default(0)).optional(),
|
||||
deductions: z.record(z.string(), z.number().default(0)).optional(),
|
||||
})
|
||||
|
||||
// 批量生成全员工资条
|
||||
router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, allowances = {}, deductions = {} } = batchGenerateSchema.parse(req.body)
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const results: any[] = []
|
||||
for (const emp of employees) {
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const allowance = allowances[emp.id] || 0
|
||||
const deduction = deductions[emp.id] || 0
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try {
|
||||
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
} catch {
|
||||
baseSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
}
|
||||
|
||||
const totalPay = baseSalary + overtimePay + allowance - deduction
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
update: { baseSalary, overtimePay, allowance, deduction, totalPay },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: emp.id,
|
||||
month,
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance,
|
||||
deduction,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
results.push(payslip)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { generated: results.length, payslips: results } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量导入加班数据 ==========
|
||||
|
||||
const batchOvertimeSchema = z.array(
|
||||
z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
monthlyWage: z.number().positive(),
|
||||
weekdayHours: z.number().min(0).default(0),
|
||||
weekendHours: z.number().min(0).default(0),
|
||||
holidayHours: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const items = batchOvertimeSchema.parse(req.body)
|
||||
const results: any[] = []
|
||||
|
||||
for (const data of items) {
|
||||
const hourlyWage = data.monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * data.weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * data.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
|
||||
update: {
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
})
|
||||
results.push(record)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { imported: results.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema } from '../schemas/portal.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 验证码临时存储(生产环境应使用 Redis)
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
// 员工端认证中间件
|
||||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未登录' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
try {
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload || payload.role !== 'EMPLOYEE') {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '无效的员工端 Token' } })
|
||||
}
|
||||
;(req as any).employee = { id: payload.id, orgId: payload.orgId }
|
||||
next()
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Token 无效或已过期' } })
|
||||
}
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalLoginSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee || !employee.passwordHash) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const valid = await bcrypt.compare(data.password, employee.passwordHash)
|
||||
if (!valid) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 发送验证码(页面内显示)
|
||||
router.post('/send-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalSendCodeSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 验证码登录
|
||||
router.post('/verify-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalVerifyCodeSchema.parse(req.body)
|
||||
const stored = codeStore.get(data.phone)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.code !== data.code) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条
|
||||
router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, month },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条确认已阅
|
||||
router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } })
|
||||
}
|
||||
await prisma.payslip.update({
|
||||
where: { id: req.params.id },
|
||||
data: { confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 我的合同
|
||||
router.get('/contract', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const contract = await prisma.laborContract.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!contract) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: contract })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 入职填报提交
|
||||
router.post('/onboarding', async (req, res, next) => {
|
||||
try {
|
||||
const data = onboardingSchema.parse(req.body)
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: data.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
await prisma.onboardingLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
employeeName: data.name,
|
||||
phone: data.phone,
|
||||
formData: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
idCard: data.idCard,
|
||||
emergencyContact: data.emergencyContact,
|
||||
emergencyPhone: data.emergencyPhone,
|
||||
address: data.address,
|
||||
bankCard: data.bankCard,
|
||||
bankName: data.bankName,
|
||||
},
|
||||
status: 'APPROVED',
|
||||
usedAt: new Date(),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { message: '信息提交成功,HR 将审核您的信息' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署确认
|
||||
router.post('/contract-confirm', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractConfirmSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
await prisma.laborContract.update({
|
||||
where: { id: link.contractId },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}` },
|
||||
})
|
||||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取入职填报信息(通过 token)
|
||||
router.get('/onboarding/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
include: { org: { select: { name: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({ success: true, data: { orgName: link.org.name } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取合同确认信息(通过 token)
|
||||
router.get('/contract-confirm/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: req.params.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: {
|
||||
contract: {
|
||||
include: {
|
||||
employee: { select: { name: true, org: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
orgName: link.contract.employee.org.name,
|
||||
employeeName: link.contract.employee.name,
|
||||
contract: link.contract,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,533 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function safeDecrypt(encrypted: string): number {
|
||||
try {
|
||||
if (!encrypted || !encrypted.includes(':')) return Number(encrypted) || 0
|
||||
return Number(decrypt(encrypted))
|
||||
} catch {
|
||||
return Number(encrypted) || 0
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 花名册聚合 API ==========
|
||||
|
||||
// 花名册列表(含汇总信息)
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
_count: {
|
||||
select: {
|
||||
disciplinaryRecords: true,
|
||||
attendanceRecords: true,
|
||||
trainingRecords: true,
|
||||
performanceRecords: true,
|
||||
payslips: true,
|
||||
overtimeRecords: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = employees.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
status: e.status,
|
||||
hireDate: e.hireDate,
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
latestContract: e.contracts[0] || null,
|
||||
counts: e._count,
|
||||
}))
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 员工完整档案(花名册详情)
|
||||
router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' }, take: 90 },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: { orderBy: { createdAt: 'desc' } },
|
||||
attachments: true,
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const { monthlySalary, ...rest } = employee
|
||||
res.json({
|
||||
success: true,
|
||||
data: { ...rest, monthlySalary: safeDecrypt(monthlySalary) },
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 仲裁证据链导出
|
||||
router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' } },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: true,
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const evidence: any[] = []
|
||||
const empName = employee.name
|
||||
const empDept = employee.department
|
||||
const hireDate = employee.hireDate.toISOString().slice(0, 10)
|
||||
|
||||
// 1. 劳动关系证据
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: '入职登记',
|
||||
date: hireDate,
|
||||
description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`,
|
||||
evidenceType: 'EMPLOYMENT',
|
||||
})
|
||||
employee.contracts.forEach((c) => {
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: `劳动合同(${c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签订'})`,
|
||||
date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10),
|
||||
description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。`,
|
||||
evidenceType: 'CONTRACT',
|
||||
signed: !!c.signDate,
|
||||
})
|
||||
})
|
||||
|
||||
// 2. 薪酬证据
|
||||
employee.payslips.forEach((p) => {
|
||||
evidence.push({
|
||||
category: '薪酬发放',
|
||||
title: `${p.month}月工资条`,
|
||||
date: p.month,
|
||||
description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}。${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`,
|
||||
evidenceType: 'PAYSLIP',
|
||||
confirmed: !!p.confirmedAt,
|
||||
})
|
||||
})
|
||||
employee.overtimeRecords.forEach((o) => {
|
||||
if (o.totalPay > 0) {
|
||||
evidence.push({
|
||||
category: '薪酬发放',
|
||||
title: `${o.month}月加班费记录`,
|
||||
date: o.month,
|
||||
description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`,
|
||||
evidenceType: 'OVERTIME',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 考勤证据
|
||||
const abnormalAttendance = employee.attendanceRecords.filter((a) => a.status !== 'NORMAL')
|
||||
abnormalAttendance.forEach((a) => {
|
||||
const statusMap: Record<string, string> = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||||
evidence.push({
|
||||
category: '考勤记录',
|
||||
title: `${a.date.toISOString().slice(0, 10)} 考勤异常`,
|
||||
date: a.date.toISOString().slice(0, 10),
|
||||
description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}。${a.remark || ''}`,
|
||||
evidenceType: 'ATTENDANCE',
|
||||
})
|
||||
})
|
||||
|
||||
// 4. 违纪证据
|
||||
employee.disciplinaryRecords.forEach((d) => {
|
||||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||||
evidence.push({
|
||||
category: '违纪处理',
|
||||
title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`,
|
||||
date: d.violationDate.toISOString().slice(0, 10),
|
||||
description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}。${d.employeeAck ? `员工已签字确认(${d.ackDate ? d.ackDate.toISOString().slice(0, 10) : ''})。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`,
|
||||
evidenceType: 'DISCIPLINARY',
|
||||
acknowledged: d.employeeAck,
|
||||
})
|
||||
})
|
||||
|
||||
// 5. 培训签收证据
|
||||
employee.trainingRecords.forEach((t) => {
|
||||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||||
evidence.push({
|
||||
category: '培训签收',
|
||||
title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`,
|
||||
date: t.trainingDate.toISOString().slice(0, 10),
|
||||
description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}。` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}。`,
|
||||
evidenceType: 'TRAINING',
|
||||
acknowledged: t.ackStatus === 'SIGNED',
|
||||
})
|
||||
})
|
||||
|
||||
// 6. 绩效证据
|
||||
employee.performanceRecords.forEach((p) => {
|
||||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||
evidence.push({
|
||||
category: '绩效考核',
|
||||
title: `${p.period} 绩效考核`,
|
||||
date: p.period,
|
||||
description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}。${p.summary ? `评语:${p.summary}。` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}。` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`,
|
||||
evidenceType: 'PERFORMANCE',
|
||||
acknowledged: p.employeeAck,
|
||||
})
|
||||
})
|
||||
|
||||
// 7. 解聘证据
|
||||
employee.terminations.forEach((t) => {
|
||||
const reasonMap: Record<string, string> = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期' }
|
||||
evidence.push({
|
||||
category: '解聘记录',
|
||||
title: `${t.terminationDate.toISOString().slice(0, 10)} 解聘记录`,
|
||||
date: t.terminationDate.toISOString().slice(0, 10),
|
||||
description: `解聘原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。${t.remark || ''}`,
|
||||
evidenceType: 'TERMINATION',
|
||||
})
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
employee: {
|
||||
name: empName,
|
||||
department: empDept,
|
||||
hireDate,
|
||||
status: employee.status,
|
||||
gender: employee.gender,
|
||||
phone: employee.phone,
|
||||
},
|
||||
evidence,
|
||||
summary: {
|
||||
total: evidence.length,
|
||||
signed: evidence.filter((e) => e.acknowledged === true).length,
|
||||
unsigned: evidence.filter((e) => e.acknowledged === false).length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 违纪记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.disciplinaryRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { violationDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
violationDate: new Date(violationDate),
|
||||
violationType,
|
||||
description,
|
||||
severity: severity || 'WARNING',
|
||||
action: action || 'ORAL_WARNING',
|
||||
actionDetail,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
witness,
|
||||
attachmentUrl,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.disciplinaryRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
violationDate: violationDate ? new Date(violationDate) : undefined,
|
||||
violationType,
|
||||
description,
|
||||
severity,
|
||||
action,
|
||||
actionDetail,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
witness,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.disciplinaryRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 考勤记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.attendanceRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 90,
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { date, checkInTime, checkOutTime, status, lateMinutes, earlyMinutes, workHours, overtimeHours, remark } = req.body
|
||||
const record = await prisma.attendanceRecord.upsert({
|
||||
where: { employeeId_date: { employeeId: req.params.employeeId, date: new Date(date) } },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
date: new Date(date),
|
||||
checkInTime,
|
||||
checkOutTime,
|
||||
status: status || 'NORMAL',
|
||||
lateMinutes: lateMinutes || 0,
|
||||
earlyMinutes: earlyMinutes || 0,
|
||||
workHours: workHours || 0,
|
||||
overtimeHours: overtimeHours || 0,
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
checkInTime,
|
||||
checkOutTime,
|
||||
status,
|
||||
lateMinutes,
|
||||
earlyMinutes,
|
||||
workHours,
|
||||
overtimeHours,
|
||||
remark,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/attendance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.attendanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.attendanceRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 培训签收记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.trainingRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { trainingDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||||
const record = await prisma.trainingRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
trainingDate: new Date(trainingDate),
|
||||
topic,
|
||||
content,
|
||||
trainer,
|
||||
duration: duration || 0,
|
||||
ackStatus: ackStatus || 'PENDING',
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
attachmentUrl,
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'TRAINING', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} 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
|
||||
const record = await prisma.trainingRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.trainingRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
trainingDate: trainingDate ? new Date(trainingDate) : undefined,
|
||||
topic,
|
||||
content,
|
||||
trainer,
|
||||
duration,
|
||||
ackStatus,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
attachmentUrl,
|
||||
remark,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.trainingRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.trainingRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 绩效记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.performanceRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { period: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = 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,
|
||||
score: score || 0,
|
||||
grade: grade || 'B',
|
||||
result: result || 'QUALIFIED',
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'PERFORMANCE', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
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 record = await prisma.performanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.performanceRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
period,
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.performanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.performanceRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
email: z.string().email().optional(),
|
||||
})
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, '姓名不能为空'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(6, '密码至少6位'),
|
||||
role: z.enum(['ADMIN', 'HR', 'VIEWER']).default('HR'),
|
||||
})
|
||||
|
||||
// 获取企业信息
|
||||
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, createdAt: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新企业信息
|
||||
router.put('/org', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name } = req.body as { name?: string }
|
||||
const org = await prisma.organization.update({
|
||||
where: { id: req.user!.orgId },
|
||||
data: name ? { name } : {},
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取用户列表
|
||||
router.get('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: users })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加用户
|
||||
router.post('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createUserSchema.parse(req.body)
|
||||
const existing = await prisma.user.findFirst({ where: { phone: data.phone, orgId: req.user!.orgId } })
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '该手机号已存在' } })
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(data.password, 10)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
passwordHash,
|
||||
role: data.role,
|
||||
},
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新用户
|
||||
router.put('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = updateUserSchema.parse(req.body)
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: data,
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除用户
|
||||
router.delete('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除自己' } })
|
||||
}
|
||||
await prisma.user.delete({ where: { id: req.params.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取社保配置
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId: req.user!.orgId },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新社保配置
|
||||
const configSchema = z.object({
|
||||
city: z.string().optional(),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
medicalOrg: z.number().optional(),
|
||||
medicalEmp: z.number().optional(),
|
||||
unemploymentOrg: z.number().optional(),
|
||||
unemploymentEmp: z.number().optional(),
|
||||
injuryOrg: z.number().optional(),
|
||||
maternityOrg: z.number().optional(),
|
||||
housingOrg: z.number().optional(),
|
||||
housingEmp: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
})
|
||||
|
||||
router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = configSchema.parse(req.body)
|
||||
const config = await prisma.socialInsuranceConfig.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
})
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算
|
||||
const calcSchema = z.object({
|
||||
base: z.number().positive(),
|
||||
})
|
||||
|
||||
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base } = calcSchema.parse(req.body)
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({ data: { orgId: req.user!.orgId } })
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
|
||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||
const medicalOrg = actualBase * config.medicalOrg / 100
|
||||
const medicalEmp = actualBase * config.medicalEmp / 100
|
||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
const maternityOrg = actualBase * config.maternityOrg / 100
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg + housingOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp + housingEmp
|
||||
const total = totalOrg + totalEmp
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
items: [
|
||||
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
|
||||
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
|
||||
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
|
||||
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
|
||||
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
|
||||
{ name: '住房公积金', orgRate: config.housingOrg, empRate: config.housingEmp, orgAmount: housingOrg, empAmount: housingEmp },
|
||||
],
|
||||
totalOrg,
|
||||
totalEmp,
|
||||
total,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { terminationChecklistSchema } from '../schemas/termination.schema'
|
||||
import { createTermination, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const result = await getTerminations(req.user!.orgId, page, pageSize)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/checklist/:reason', authMiddleware, (req: AuthRequest, res) => {
|
||||
const checklist = getChecklistForReason(req.params.reason)
|
||||
res.json({ success: true, data: checklist })
|
||||
})
|
||||
|
||||
router.get('/assess/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: req.params.employeeId, orgId: req.user!.orgId } })
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const assessment = assessRisk(employee, req.query.reason as string || '')
|
||||
res.json({ success: true, data: assessment })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = terminationChecklistSchema.parse(req.body)
|
||||
const result = await createTermination(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'TERMINATE', 'EMPLOYEE', data.employeeId, { reason: data.reason })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const registerSchema = z.object({
|
||||
orgName: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: '两次密码不一致',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export const loginSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
|
||||
export const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1, '缺少 refreshToken'),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createEmployeeSchema = z.object({
|
||||
name: z.string().min(1, '姓名不能为空').max(30, '姓名最多30个字'),
|
||||
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
|
||||
hireDate: z.string().datetime(),
|
||||
monthlySalary: z.string().min(1, '月薪不能为空'),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
isPregnant: z.boolean().default(false),
|
||||
isInMedicalPeriod: z.boolean().default(false),
|
||||
isWorkInjured: z.boolean().default(false),
|
||||
contract: z.object({
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime().nullable(),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
|
||||
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||
contractYears: z.number().int().min(1).max(10).default(3),
|
||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
||||
probationSalary: z.number().min(0).default(0),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const updateEmployeeSchema = z.object({
|
||||
name: z.string().min(1).max(30).optional(),
|
||||
department: z.string().min(1).max(50).optional(),
|
||||
hireDate: z.string().datetime().optional(),
|
||||
monthlySalary: z.string().min(1).optional(),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
isPregnant: z.boolean().optional(),
|
||||
isInMedicalPeriod: z.boolean().optional(),
|
||||
isWorkInjured: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
contractIds: z.array(z.string()).min(1, '至少选择一个合同'),
|
||||
years: z.number().int().min(1).max(5).default(3),
|
||||
})
|
||||
|
||||
export const addContractSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime().nullable(),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
|
||||
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||
contractYears: z.number().int().min(1).max(10).default(3),
|
||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
||||
probationSalary: z.number().min(0).default(0),
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const portalLoginSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(6, '密码至少6位'),
|
||||
})
|
||||
|
||||
export const portalSendCodeSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
})
|
||||
|
||||
export const portalVerifyCodeSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
code: z.string().length(6, '验证码为6位数字'),
|
||||
})
|
||||
|
||||
export const onboardingSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
name: z.string().min(1, '姓名不能为空'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
idCard: z.string().min(15, '身份证号格式不正确').max(18),
|
||||
emergencyContact: z.string().optional(),
|
||||
emergencyPhone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
bankCard: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
})
|
||||
|
||||
export const contractConfirmSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
agreed: z.boolean().refine((v) => v === true, '请勾选确认签署'),
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const terminationChecklistSchema = z.object({
|
||||
employeeId: z.string().min(1, '请选择员工'),
|
||||
reason: z.enum(['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF', 'EXPIRED']),
|
||||
terminationDate: z.string().datetime(),
|
||||
compensation: z.number().min(0).default(0),
|
||||
checklist: z.record(z.boolean()).default({}),
|
||||
remark: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export const terminationQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
pageSize: z.coerce.number().min(1).max(50).default(20),
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY || ''
|
||||
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
|
||||
const client = new OpenAI({ apiKey, baseURL })
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。
|
||||
|
||||
你的职责:
|
||||
1. 回答用户关于劳动用工的合规问题
|
||||
2. 基于企业实际数据给出针对性建议
|
||||
3. 引用具体法律条文作为依据
|
||||
4. 用通俗易懂的语言解释法律问题
|
||||
|
||||
回答要求:
|
||||
- 先给出直接结论,再展开解释
|
||||
- 引用法律条文时标注具体法律名称和条款号
|
||||
- 涉及金额时给出计算过程
|
||||
- 如有关联的企业数据,在回答中提及
|
||||
- 回答简洁有力,避免冗长`
|
||||
|
||||
export async function chat(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
|
||||
const systemMessage = orgContext
|
||||
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}`
|
||||
: SYSTEM_PROMPT
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
...messages,
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function reviewContract(contractText: string) {
|
||||
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
|
||||
|
||||
合同文本:
|
||||
${contractText}
|
||||
|
||||
请按以下格式输出:
|
||||
【风险项】
|
||||
🔴/🟡/🟢 [问题标题] - [说明] - [修改建议]
|
||||
|
||||
【合规评分】XX/100
|
||||
|
||||
【总体建议】
|
||||
一段话总结`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-max',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function matchCase(scenario: string) {
|
||||
const prompt = `作为一个劳动法案例匹配专家,请分析以下劳动争议情形,匹配相似的仲裁/诉讼案例,评估败诉风险。
|
||||
|
||||
争议情形:
|
||||
${scenario}
|
||||
|
||||
请按以下格式输出:
|
||||
【相似案例】
|
||||
案例1:[案例标题]
|
||||
- 情形:[简要描述]
|
||||
- 结果:[判决结果]
|
||||
- 赔偿金额:[金额]
|
||||
- 相似度:XX%
|
||||
|
||||
案例2:...
|
||||
|
||||
【败诉风险评估】
|
||||
风险等级:高/中/低(XX%)
|
||||
原因:[分析]
|
||||
|
||||
【建议】
|
||||
[降低风险的具体建议]`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-max',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动法案例分析专家,熟悉劳动仲裁和诉讼案例。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function predictRisks(orgContext: string) {
|
||||
const prompt = `基于以下企业用工数据,预测未来30天可能出现的合规风险,并给出优先级建议。
|
||||
|
||||
企业数据:
|
||||
${orgContext}
|
||||
|
||||
请按以下格式输出:
|
||||
【未来30天预计风险】
|
||||
- [员工姓名/风险描述] → [建议措施]
|
||||
|
||||
【优先级建议】
|
||||
[先处理什么,再处理什么]`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动用工风险预测专家,能基于企业数据分析未来风险趋势。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 1500,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../lib/jwt'
|
||||
|
||||
export async function register(orgName: string, phone: string, password: string) {
|
||||
const existing = await prisma.user.findUnique({ where: { phone } })
|
||||
if (existing) {
|
||||
throw { code: 'DUPLICATE', message: '该手机号已注册' }
|
||||
}
|
||||
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: orgName,
|
||||
plan: 'FREE',
|
||||
maxEmployees: 20,
|
||||
},
|
||||
})
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
phone,
|
||||
name: '管理员',
|
||||
passwordHash,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
|
||||
return {
|
||||
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(phone: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw { code: 'AUTH_FAILED', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
|
||||
return {
|
||||
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string) {
|
||||
const payload = verifyRefreshToken(refreshToken)
|
||||
if (!payload) {
|
||||
throw { code: 'TOKEN_INVALID', message: 'Refresh Token 无效或已过期' }
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '用户不存在' }
|
||||
}
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
return { accessToken }
|
||||
}
|
||||
|
||||
export async function resetPassword(phone: string, newPassword: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '手机号未注册' }
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { encrypt, decrypt } from '../lib/crypto'
|
||||
import { runRiskDetection } from './risk.service'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function getContractStatus(contract: {
|
||||
signDate: Date | null
|
||||
startDate: Date
|
||||
endDate: Date | null
|
||||
contractType: string
|
||||
hireDate: Date
|
||||
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
|
||||
const today = new Date()
|
||||
|
||||
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(today, contract.hireDate)
|
||||
if (days > 365) {
|
||||
return { status: 'unsigned_over_year', statusText: '已视为无固定期限', riskLevel: 'high' }
|
||||
} else if (days > 30) {
|
||||
return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' }
|
||||
}
|
||||
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
|
||||
if (contract.endDate) {
|
||||
const daysToExpire = daysBetween(contract.endDate, today)
|
||||
if (daysToExpire < 0) {
|
||||
return { status: 'expired', statusText: '已到期未续签', riskLevel: 'high' }
|
||||
} else if (daysToExpire <= 30) {
|
||||
return { status: 'expiring', statusText: `即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
return { status: 'active', statusText: '正常', riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
return { status: 'unfixed', statusText: '无固定期限', riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
|
||||
let max = 0
|
||||
if (contractMonths >= 36) max = 6
|
||||
else if (contractMonths >= 12) max = 2
|
||||
else if (contractMonths >= 3) max = 1
|
||||
|
||||
if (probationMonths > max) {
|
||||
return {
|
||||
valid: false,
|
||||
max,
|
||||
message: `${contractMonths}个月合同试用期最多${max}个月,当前${probationMonths}个月不合法`,
|
||||
}
|
||||
}
|
||||
return { valid: true, max }
|
||||
}
|
||||
|
||||
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 20
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const where: any = { orgId, status: 'ACTIVE' }
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: params.search } },
|
||||
{ phone: { contains: params.search } },
|
||||
]
|
||||
}
|
||||
if (params.department) {
|
||||
where.department = params.department
|
||||
}
|
||||
|
||||
const [total, employees] = await Promise.all([
|
||||
prisma.employee.count({ where }),
|
||||
prisma.employee.findMany({
|
||||
where,
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
])
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
const latestContract = emp.contracts[0]
|
||||
const contractInfo = latestContract
|
||||
? getContractStatus({
|
||||
signDate: latestContract.signDate,
|
||||
startDate: latestContract.startDate,
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: emp.hireDate,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
startDate: emp.hireDate,
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: emp.hireDate,
|
||||
})
|
||||
|
||||
let decryptedSalary = 0
|
||||
try {
|
||||
decryptedSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
} catch {
|
||||
decryptedSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
||||
status: emp.status,
|
||||
monthlySalary: decryptedSalary,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
isPregnant: emp.isPregnant,
|
||||
isInMedicalPeriod: emp.isInMedicalPeriod,
|
||||
isWorkInjured: emp.isWorkInjured,
|
||||
}
|
||||
})
|
||||
|
||||
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
|
||||
export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id, orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
riskItems: { where: { status: 'PENDING' }, orderBy: { level: 'asc' } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
let decryptedSalary = 0
|
||||
try {
|
||||
decryptedSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||
} catch {
|
||||
decryptedSalary = Number(employee.monthlySalary) || 0
|
||||
}
|
||||
|
||||
return {
|
||||
...employee,
|
||||
monthlySalary: decryptedSalary,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId,
|
||||
name: data.name,
|
||||
department: data.department,
|
||||
hireDate: new Date(data.hireDate),
|
||||
monthlySalary: encrypt(data.monthlySalary),
|
||||
gender: data.gender,
|
||||
phone: data.phone,
|
||||
isPregnant: data.isPregnant || false,
|
||||
isInMedicalPeriod: data.isInMedicalPeriod || false,
|
||||
isWorkInjured: data.isWorkInjured || false,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
|
||||
const contractMonths = data.contract.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
|
||||
: data.contract.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
|
||||
startDate: new Date(data.contract.startDate),
|
||||
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
|
||||
contractType: data.contract.contractType,
|
||||
signMethod: data.contract.signMethod || 'PAPER',
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id: employee.id }
|
||||
}
|
||||
|
||||
export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (data.name !== undefined) updateData.name = data.name
|
||||
if (data.department !== undefined) updateData.department = data.department
|
||||
if (data.hireDate !== undefined) updateData.hireDate = new Date(data.hireDate)
|
||||
if (data.monthlySalary !== undefined) updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
if (data.gender !== undefined) updateData.gender = data.gender
|
||||
if (data.phone !== undefined) updateData.phone = data.phone
|
||||
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
|
||||
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
|
||||
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: updateData })
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function deleteEmployee(orgId: string, id: string) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: { status: 'RESIGNED' } })
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: id, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function batchRenew(orgId: string, userId: string, contractIds: string[], years: number) {
|
||||
const contracts = await prisma.laborContract.findMany({
|
||||
where: { id: { in: contractIds }, orgId },
|
||||
})
|
||||
|
||||
if (contracts.length === 0) {
|
||||
throw { code: 'NOT_FOUND', message: '未找到符合条件的合同' }
|
||||
}
|
||||
|
||||
for (const contract of contracts) {
|
||||
const newStartDate = contract.endDate || new Date()
|
||||
const newEndDate = new Date(newStartDate)
|
||||
newEndDate.setFullYear(newEndDate.getFullYear() + years)
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: contract.employeeId,
|
||||
signDate: new Date(),
|
||||
startDate: newStartDate,
|
||||
endDate: newEndDate,
|
||||
contractType: contract.contractType,
|
||||
signMethod: contract.signMethod,
|
||||
contractYears: years,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
renewalCount: contract.renewalCount + 1,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { renewed: contracts.length }
|
||||
}
|
||||
|
||||
export async function addContract(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const contractMonths = data.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.endDate), new Date(data.startDate)) / 30.44)
|
||||
: data.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
const contract = await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
signDate: data.signDate ? new Date(data.signDate) : null,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||
contractType: data.contractType,
|
||||
signMethod: data.signMethod || 'PAPER',
|
||||
contractYears: data.contractYears || 3,
|
||||
probationMonths: data.probationMonths || 0,
|
||||
probationSalary: data.probationSalary || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id: contract.id }
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import type { ContractType, RiskLevel, RiskType } from '@prisma/client'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export async function detectContractRisks(orgId: string) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' } } },
|
||||
})
|
||||
|
||||
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const emp of employees) {
|
||||
const latestContract = emp.contracts[0]
|
||||
|
||||
if (!latestContract || latestContract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(new Date(), emp.hireDate)
|
||||
if (days > 365) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}入职${days}天未签合同,已视为无固定期限`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过1年未签订书面合同,法律上已视为无固定期限劳动合同。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (days > 30) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}入职${days}天未签合同`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过30天未签订书面合同,需尽快补签。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'LOW',
|
||||
title: `${emp.name}入职${days}天,尚未签合同`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},30天内需签订书面合同。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (latestContract.endDate) {
|
||||
const daysToExpire = daysBetween(latestContract.endDate, new Date())
|
||||
if (daysToExpire < 0) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}的合同已到期${Math.abs(daysToExpire)}天未续签`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},已过期未续签。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (daysToExpire <= 30) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (latestContract.probationMonths > 0) {
|
||||
const contractMonths = latestContract.endDate
|
||||
? Math.ceil(daysBetween(latestContract.endDate, latestContract.startDate) / 30.44)
|
||||
: 36
|
||||
let maxProbation = 0
|
||||
if (contractMonths >= 36) maxProbation = 6
|
||||
else if (contractMonths >= 12) maxProbation = 2
|
||||
else if (contractMonths >= 3) maxProbation = 1
|
||||
|
||||
if (latestContract.probationMonths > maxProbation) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}试用期${latestContract.probationMonths}个月可能不合法`,
|
||||
description: `${contractMonths}个月合同试用期最多${maxProbation}个月,当前${latestContract.probationMonths}个月超出法定上限。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function detectTerminationRisks(orgId: string) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
})
|
||||
|
||||
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const emp of employees) {
|
||||
if (emp.isPregnant) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
if (emp.isInMedicalPeriod) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}处于医疗期,解聘需谨慎`,
|
||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
if (emp.isWorkInjured) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}工伤期间,解聘受限`,
|
||||
description: '工伤职工在停工留薪期内不得解除劳动合同。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function detectMonthlyTasks(orgId: string) {
|
||||
const setting = await prisma.notificationSetting.findUnique({ where: { orgId } })
|
||||
if (!setting) return []
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const today = now.getDate()
|
||||
|
||||
const tasks = [
|
||||
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' },
|
||||
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' },
|
||||
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' },
|
||||
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' },
|
||||
]
|
||||
|
||||
const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const task of tasks) {
|
||||
// 当月已过截止日或正好到截止日时生成提醒
|
||||
if (today >= task.day) {
|
||||
risks.push({
|
||||
employeeId: null,
|
||||
type: 'MONTHLY',
|
||||
level: today > task.day + 3 ? 'HIGH' : 'MEDIUM',
|
||||
title: task.title,
|
||||
description: task.desc,
|
||||
actionUrl: task.url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function runRiskDetection(orgId: string) {
|
||||
const existingRisks = await prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
})
|
||||
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.title}`))
|
||||
|
||||
// 月度任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的月度任务被重新创建
|
||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
|
||||
const monthlyExisting = await prisma.riskItem.findMany({
|
||||
where: { orgId, type: 'MONTHLY', title: { startsWith: `${currentMonth}月` } },
|
||||
select: { employeeId: true, title: true },
|
||||
})
|
||||
const monthlyKeys = new Set(monthlyExisting.map((r: typeof monthlyExisting[number]) => `${r.employeeId}:${r.title}`))
|
||||
|
||||
const contractRisks = await detectContractRisks(orgId)
|
||||
const terminationRisks = await detectTerminationRisks(orgId)
|
||||
const monthlyTasks = await detectMonthlyTasks(orgId)
|
||||
|
||||
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
|
||||
const nonMonthlyRisks = [...contractRisks, ...terminationRisks]
|
||||
const toCreate = [
|
||||
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
]
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.riskItem.createMany({
|
||||
data: toCreate.map((r) => ({
|
||||
orgId,
|
||||
employeeId: r.employeeId,
|
||||
type: r.type,
|
||||
level: r.level,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return toCreate.length
|
||||
}
|
||||
|
||||
export async function getDashboardData(orgId: string) {
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59)
|
||||
|
||||
const [
|
||||
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
|
||||
overtimeRecords, payslips, socialConfig,
|
||||
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
||||
monthSeverancePay,
|
||||
] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH' } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
include: { employee: true },
|
||||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
|
||||
take: 10,
|
||||
}),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'RESOLVED' },
|
||||
include: { employee: true },
|
||||
orderBy: { resolvedAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
prisma.overtimeRecord.findMany({
|
||||
where: { orgId, month: currentMonth },
|
||||
select: { totalPay: true, weekdayHours: true, weekendHours: true, holidayHours: true },
|
||||
}),
|
||||
prisma.payslip.findMany({
|
||||
where: { orgId, month: currentMonth },
|
||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, totalPay: true, confirmedAt: true },
|
||||
}),
|
||||
prisma.socialInsuranceConfig.findUnique({ where: { orgId } }),
|
||||
prisma.laborContract.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.terminationRecord.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.disciplinaryRecord.count({
|
||||
where: { orgId, violationDate: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.attendanceRecord.count({
|
||||
where: { orgId, date: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.terminationRecord.aggregate({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
_sum: { compensation: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const monthlyOvertimePay = overtimeRecords.reduce((sum: number, r: typeof overtimeRecords[number]) => sum + r.totalPay, 0)
|
||||
|
||||
// 本月薪税汇总
|
||||
const totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0)
|
||||
const totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0)
|
||||
const totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0)
|
||||
const totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0)
|
||||
const totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0)
|
||||
const confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
|
||||
|
||||
// 社保公积金估算(基于在职员工数 × 社保配置)
|
||||
let socialOrgTotal = 0
|
||||
let socialEmpTotal = 0
|
||||
let housingOrgTotal = 0
|
||||
let housingEmpTotal = 0
|
||||
if (socialConfig && employeeCount > 0) {
|
||||
// 用平均工资作为估算基数
|
||||
const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin
|
||||
socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount
|
||||
socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount
|
||||
housingOrgTotal = avgBase * socialConfig.housingOrg / 100 * employeeCount
|
||||
housingEmpTotal = avgBase * socialConfig.housingEmp / 100 * employeeCount
|
||||
}
|
||||
|
||||
// 个税估算(简化:应纳税所得额 = 税前工资 - 5000起征点 - 社保个人部分 - 公积金个人部分)
|
||||
const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal)
|
||||
// 累计预扣法简化:月度个税估算
|
||||
let estimatedTax = 0
|
||||
if (taxableIncome > 0) {
|
||||
if (taxableIncome <= 3000) estimatedTax = taxableIncome * 0.03
|
||||
else if (taxableIncome <= 12000) estimatedTax = 3000 * 0.03 + (taxableIncome - 3000) * 0.1
|
||||
else if (taxableIncome <= 25000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + (taxableIncome - 12000) * 0.2
|
||||
else if (taxableIncome <= 35000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + (taxableIncome - 25000) * 0.25
|
||||
else if (taxableIncome <= 55000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + (taxableIncome - 35000) * 0.3
|
||||
else if (taxableIncome <= 80000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + (taxableIncome - 55000) * 0.35
|
||||
else estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + 25000 * 0.35 + (taxableIncome - 80000) * 0.45
|
||||
}
|
||||
|
||||
const payrollSummary = {
|
||||
month: currentMonth,
|
||||
employeeCount,
|
||||
payslipCount: payslips.length,
|
||||
confirmedPayslips,
|
||||
unconfirmedPayslips: payslips.length - confirmedPayslips,
|
||||
baseSalary: totalBaseSalary,
|
||||
overtimePay: totalOvertimePay,
|
||||
allowance: totalAllowance,
|
||||
deduction: totalDeduction,
|
||||
totalPay,
|
||||
socialOrg: socialOrgTotal,
|
||||
socialEmp: socialEmpTotal,
|
||||
housingOrg: housingOrgTotal,
|
||||
housingEmp: housingEmpTotal,
|
||||
estimatedTax,
|
||||
severancePay: monthSeverancePay._sum.compensation || 0,
|
||||
// 企业总成本 = 工资总额 + 企业社保 + 企业公积金 + 经济补偿金
|
||||
orgTotalCost: totalPay + socialOrgTotal + housingOrgTotal + (monthSeverancePay._sum.compensation || 0),
|
||||
// 员工实发 = 工资总额 - 个人社保 - 个人公积金 - 个税
|
||||
empNetPay: totalPay - socialEmpTotal - housingEmpTotal - estimatedTax,
|
||||
}
|
||||
|
||||
// 本月工作动态
|
||||
const monthlyActivities = {
|
||||
month: currentMonth,
|
||||
newContracts: monthContracts,
|
||||
terminations: monthTerminations,
|
||||
disciplinaryActions: monthDisciplinary,
|
||||
attendanceRecords: monthAttendance,
|
||||
overtimeHours: overtimeRecords.reduce((s: number, r: typeof overtimeRecords[number]) => s + r.weekdayHours + r.weekendHours + r.holidayHours, 0),
|
||||
overtimePay: monthlyOvertimePay,
|
||||
}
|
||||
|
||||
const riskDistribution = {
|
||||
contract: riskItems.filter((r: typeof riskItems[number]) => r.type === 'CONTRACT').length,
|
||||
salary: riskItems.filter((r: typeof riskItems[number]) => r.type === 'SALARY').length,
|
||||
termination: riskItems.filter((r: typeof riskItems[number]) => r.type === 'TERMINATION').length,
|
||||
}
|
||||
|
||||
const todos = riskItems.map((r: typeof riskItems[number]) => ({
|
||||
id: r.id,
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
}))
|
||||
|
||||
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
|
||||
id: r.id,
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
resolvedAt: r.resolvedAt?.toISOString() || null,
|
||||
}))
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 12
|
||||
? `早上好!今天有 ${pendingRisks} 件事需要处理`
|
||||
: hour < 18
|
||||
? `下午好!今天有 ${pendingRisks} 件事需要处理`
|
||||
: `晚上好!今天有 ${pendingRisks} 件事需要处理`
|
||||
|
||||
return {
|
||||
greeting,
|
||||
stats: {
|
||||
employeeCount,
|
||||
highRiskCount: highRisks,
|
||||
todoCount: pendingRisks,
|
||||
monthlyOvertimePay,
|
||||
},
|
||||
todos,
|
||||
resolvedTodos,
|
||||
riskDistribution,
|
||||
aiPrediction: null,
|
||||
payrollSummary,
|
||||
monthlyActivities,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { RiskAssessment } from '@prisma/client'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function getChecklistForReason(reason: string): { key: string; label: string }[] {
|
||||
switch (reason) {
|
||||
case 'NEGOTIATED':
|
||||
return [
|
||||
{ key: 'compensation_paid', label: '是否已支付经济补偿金' },
|
||||
{ key: 'agreement_signed', label: '是否签署协商解除协议' },
|
||||
{ key: 'final_pay_ready', label: '是否结清最后工资' },
|
||||
]
|
||||
case 'FAULT':
|
||||
return [
|
||||
{ key: 'has_rules', label: '是否有规章制度依据' },
|
||||
{ key: 'has_evidence', label: '是否有违纪证据' },
|
||||
{ key: 'notify_union', label: '是否事先通知工会' },
|
||||
{ key: 'written_notice', label: '是否出具书面解除通知' },
|
||||
]
|
||||
case 'NONFAULT':
|
||||
return [
|
||||
{ key: 'medical_period_end', label: '医疗期是否已届满' },
|
||||
{ key: 'training_given', label: '是否经过培训或调岗' },
|
||||
{ key: 'compensation_paid', label: '是否支付经济补偿金' },
|
||||
{ key: 'advance_notice', label: '是否提前30天通知或支付代通知金' },
|
||||
]
|
||||
case 'LAYOFF':
|
||||
return [
|
||||
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明' },
|
||||
{ key: 'listen_opinions', label: '是否听取工会或职工意见' },
|
||||
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告' },
|
||||
{ key: 'compensation_paid', label: '是否支付经济补偿金' },
|
||||
]
|
||||
case 'EXPIRED':
|
||||
return [
|
||||
{ key: 'compensation_paid', label: '是否支付经济补偿金(如需)' },
|
||||
{ key: 'written_notice', label: '是否提前通知员工不续签' },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function assessRisk(employee: any, reason: string): { level: RiskAssessment; warnings: string[] } {
|
||||
const warnings: string[] = []
|
||||
|
||||
if (employee.isPregnant) {
|
||||
warnings.push('该员工在孕期/哺乳期,法律禁止以非过错理由解除')
|
||||
}
|
||||
if (employee.isWorkInjured) {
|
||||
warnings.push('工伤期间不得解除劳动合同')
|
||||
}
|
||||
if (employee.isInMedicalPeriod && reason !== 'FAULT') {
|
||||
warnings.push('医疗期内不得解除劳动合同(非过错理由)')
|
||||
}
|
||||
|
||||
let level: RiskAssessment = 'SAFE'
|
||||
if (warnings.length > 0) {
|
||||
level = 'DANGER'
|
||||
}
|
||||
|
||||
return { level, warnings }
|
||||
}
|
||||
|
||||
export async function createTermination(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const { level } = assessRisk(employee, data.reason)
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
reason: data.reason,
|
||||
terminationDate: new Date(data.terminationDate),
|
||||
compensation: data.compensation || 0,
|
||||
riskLevel: level,
|
||||
checklist: data.checklist || {},
|
||||
remark: data.remark,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: data.employeeId },
|
||||
data: { status: 'RESIGNED' },
|
||||
})
|
||||
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: data.employeeId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
return { id: record.id }
|
||||
}
|
||||
|
||||
export async function getTerminations(orgId: string, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const [total, records] = await Promise.all([
|
||||
prisma.terminationRecord.count({ where: { orgId } }),
|
||||
prisma.terminationRecord.findMany({
|
||||
where: { orgId },
|
||||
include: { employee: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
items: records.map((r) => ({
|
||||
id: r.id,
|
||||
employeeName: r.employee.name,
|
||||
department: r.employee.department,
|
||||
reason: r.reason,
|
||||
terminationDate: r.terminationDate.toISOString().slice(0, 10),
|
||||
compensation: r.compensation,
|
||||
riskLevel: r.riskLevel,
|
||||
createdAt: r.createdAt.toISOString().slice(0, 10),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateCompensation(hireDate: Date, leaveDate: Date, monthlyWage: number, socialAvgWage: number = 0): {
|
||||
years: number
|
||||
remainingMonths: number
|
||||
compMonths: number
|
||||
totalPay: number
|
||||
capped: boolean
|
||||
} {
|
||||
const totalMonths = (leaveDate.getFullYear() - hireDate.getFullYear()) * 12 + (leaveDate.getMonth() - hireDate.getMonth())
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const remainingMonths = totalMonths % 12
|
||||
|
||||
let compMonths: number
|
||||
if (remainingMonths >= 6) compMonths = years + 1
|
||||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||||
else compMonths = years
|
||||
|
||||
if (compMonths <= 0) compMonths = 0.5
|
||||
|
||||
let wage = monthlyWage
|
||||
let capped = false
|
||||
if (socialAvgWage > 0 && monthlyWage > socialAvgWage * 3) {
|
||||
wage = socialAvgWage * 3
|
||||
compMonths = Math.min(compMonths, 12)
|
||||
capped = true
|
||||
}
|
||||
|
||||
return { years, remainingMonths, compMonths, totalPay: wage * compMonths, capped }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>用工合规助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3167
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "hr-compliance-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"axios": "^1.7.0",
|
||||
"zustand": "^4.5.0",
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"zod": "^3.23.0",
|
||||
"lucide-react": "^0.428.0",
|
||||
"qrcode.react": "^4.0.1",
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import TopNav from './components/layout/TopNav'
|
||||
import MobileTabBar from './components/layout/MobileTabBar'
|
||||
import PageContainer from './components/layout/PageContainer'
|
||||
import Login from './pages/auth/Login'
|
||||
import Register from './pages/auth/Register'
|
||||
import ForgotPassword from './pages/auth/ForgotPassword'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Contracts from './pages/Contracts'
|
||||
import Money from './pages/Money'
|
||||
import Roster from './pages/Roster'
|
||||
import Termination from './pages/Termination'
|
||||
import AIAssistant from './pages/AIAssistant'
|
||||
import Settings from './pages/Settings'
|
||||
import PortalLogin from './pages/portal/PortalLogin'
|
||||
import Payslip from './pages/portal/Payslip'
|
||||
import MyContract from './pages/portal/MyContract'
|
||||
import Onboarding from './pages/portal/Onboarding'
|
||||
import ContractConfirm from './pages/portal/ContractConfirm'
|
||||
import OnboardingGuide from './components/OnboardingGuide'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
if (isAuthenticated) return <Navigate to="/" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 py-6 pb-20 md:pb-6">
|
||||
<PageContainer>{children}</PageContainer>
|
||||
</main>
|
||||
<MobileTabBar />
|
||||
<OnboardingGuide />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<main className="max-w-md mx-auto py-6 px-4">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* 管理端认证页面 */}
|
||||
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
|
||||
<Route path="/forgot-password" element={<PublicRoute><ForgotPassword /></PublicRoute>} />
|
||||
|
||||
{/* 管理端业务页面 */}
|
||||
<Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
{/* 员工端 */}
|
||||
<Route path="/portal/login" element={<PortalLayout><PortalLogin /></PortalLayout>} />
|
||||
<Route path="/portal/payslip" element={<PortalLayout><Payslip /></PortalLayout>} />
|
||||
<Route path="/portal/contract" element={<PortalLayout><MyContract /></PortalLayout>} />
|
||||
<Route path="/portal/onboarding" element={<PortalLayout><Onboarding /></PortalLayout>} />
|
||||
<Route path="/portal/contract-confirm" element={<PortalLayout><ContractConfirm /></PortalLayout>} />
|
||||
|
||||
{/* 兜底 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, ArrowRight } from 'lucide-react'
|
||||
|
||||
const STORAGE_KEY = 'hr-onboarding-completed'
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: '🏠',
|
||||
title: '这里看风险',
|
||||
description: '首页展示企业用工风险总览,红色代表高风险项,点击「去处理」直接跳转操作。',
|
||||
},
|
||||
{
|
||||
icon: '�',
|
||||
title: '这里管花名册',
|
||||
description: '花名册页面管理员工档案、劳动合同、附件,以及违纪、考勤、培训、绩效记录,可生成仲裁证据链。',
|
||||
},
|
||||
{
|
||||
icon: '💰',
|
||||
title: '这里算薪税',
|
||||
description: '薪税页面提供加班费、双倍工资、社保公积金计算器和工资条管理,输入参数实时计算。',
|
||||
},
|
||||
]
|
||||
|
||||
export default function OnboardingGuide() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const completed = localStorage.getItem(STORAGE_KEY)
|
||||
if (!completed) {
|
||||
setVisible(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const close = () => {
|
||||
localStorage.setItem(STORAGE_KEY, '1')
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<div className="flex justify-between">
|
||||
{step > 0 ? (
|
||||
<button onClick={() => setStep(step - 1)} className="text-sm text-gray-500">上一步</button>
|
||||
) : <span />}
|
||||
<button
|
||||
onClick={() => isLast ? close() : setStep(step + 1)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-primary"
|
||||
>
|
||||
{isLast ? '开始使用' : '下一步'}
|
||||
{!isLast && <ArrowRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Home, FileText, Users, Calculator, UserX, Bot } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: '总览', icon: Home },
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/money', label: '薪税', icon: Calculator },
|
||||
{ path: '/termination', label: '解聘', icon: UserX },
|
||||
{ path: '/ai-assistant', label: 'AI', icon: Bot },
|
||||
]
|
||||
|
||||
export default function MobileTabBar() {
|
||||
const location = useLocation()
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex justify-around items-center h-14 z-50">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = location.pathname === tab.path
|
||||
return (
|
||||
<Link
|
||||
key={tab.path}
|
||||
to={tab.path}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full',
|
||||
active ? 'text-primary' : 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-xs">{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={clsx('max-w-content mx-auto px-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Building2, AlertCircle, ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: '总览' },
|
||||
{ path: '/roster', label: '花名册' },
|
||||
{ path: '/money', label: '薪税' },
|
||||
{ path: '/termination', label: '解聘' },
|
||||
{ path: '/ai-assistant', label: 'AI顾问' },
|
||||
]
|
||||
|
||||
export default function TopNav() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
|
||||
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
|
||||
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 shrink-0">
|
||||
<Building2 className="w-5 h-5 text-primary" />
|
||||
<span className="hidden sm:inline">用工合规助手</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
to={tab.path}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors relative',
|
||||
location.pathname === tab.path
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-gray-600 hover:bg-gray-100',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.path === '/' && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger text-white text-xs rounded-full flex items-center justify-center hidden">
|
||||
0
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<span className="text-sm text-gray-700 hidden sm:inline">{user?.name || '用户'}</span>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 mt-1 w-40 bg-white rounded-md shadow-lg border border-gray-200 z-20">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
设置
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ButtonHTMLAttributes } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export default function Button({ variant = 'primary', size = 'md', className, children, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
'bg-primary text-white hover:bg-primary-dark': variant === 'primary',
|
||||
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
|
||||
'bg-danger text-white hover:bg-red-700': variant === 'danger',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-sm': size === 'md',
|
||||
'px-6 py-3 text-base': size === 'lg',
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { HTMLAttributes } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-4', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import Button from './Button'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="text-gray-300 mb-4">
|
||||
{icon || <Inbox className="w-12 h-12" />}
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-gray-900 mb-1">{title}</h3>
|
||||
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
|
||||
{actionLabel && onAction && (
|
||||
<Button onClick={onAction}>{actionLabel}</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { InputHTMLAttributes, SelectHTMLAttributes, forwardRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
function Input({ className, ...props }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
|
||||
function Select({ className, children, ...props }, ref) {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm bg-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export function Label({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <label className={clsx('block text-sm font-medium text-gray-700 mb-1', className)}>{children}</label>
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Modal({ open, onClose, title, children, className }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto', className)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-900">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Level = 'high' | 'medium' | 'low' | 'safe'
|
||||
|
||||
const colors: Record<Level, string> = {
|
||||
high: 'bg-danger',
|
||||
medium: 'bg-warning',
|
||||
low: 'bg-yellow-400',
|
||||
safe: 'bg-safe',
|
||||
}
|
||||
|
||||
const labels: Record<Level, string> = {
|
||||
high: '🔴',
|
||||
medium: '🟡',
|
||||
low: '🟡',
|
||||
safe: '🟢',
|
||||
}
|
||||
|
||||
export default function Signal({ level, label }: { level: Level; label?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className={clsx('w-2 h-2 rounded-full', colors[level])} />
|
||||
{label && <span className="text-gray-700">{label}</span>}
|
||||
{!label && <span>{labels[level]}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-surface text-gray-900 antialiased;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply box-border;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-primary text-white hover:bg-primary-dark;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-100 text-gray-700 hover:bg-gray-200;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-danger text-white hover:bg-red-700;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
|
||||
}
|
||||
.input {
|
||||
@apply w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm;
|
||||
}
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
let isRefreshing = false
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
if (isRefreshing) return Promise.reject(error)
|
||||
isRefreshing = true
|
||||
try {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
if (!refreshToken) throw new Error('No refresh token')
|
||||
const res = await axios.post('/api/v1/auth/refresh', { refreshToken })
|
||||
const newToken = res.data.data.accessToken
|
||||
useAuthStore.getState().updateToken(newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return api(originalRequest)
|
||||
} catch {
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2 } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
const QUICK_QUESTIONS = [
|
||||
'员工入职没签合同怎么办?',
|
||||
'加班费怎么算?',
|
||||
'辞退员工需要赔多少?',
|
||||
'试用期最长可以约定几个月?',
|
||||
]
|
||||
|
||||
export default function AIAssistant() {
|
||||
const [tab, setTab] = useState<Tab>('chat')
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: typeof Bot }[] = [
|
||||
{ key: 'chat', label: '智能问答', icon: Bot },
|
||||
{ key: 'predict', label: '风险预测', icon: Sparkles },
|
||||
{ key: 'review', label: '合同审查', icon: FileSearch },
|
||||
{ key: 'case', label: '案例匹配', icon: Scale },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">AI 合规顾问</h1>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === 'chat' && <ChatTab />}
|
||||
{tab === 'predict' && <PredictTab />}
|
||||
{tab === 'review' && <ReviewTab />}
|
||||
{tab === 'case' && <CaseTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatTab() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' },
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}, [messages])
|
||||
|
||||
const send = async (text?: string) => {
|
||||
const content = text || input.trim()
|
||||
if (!content || loading) return
|
||||
|
||||
const newMessages = [...messages, { role: 'user' as const, content }]
|
||||
setMessages([...newMessages, { role: 'assistant', content: '' }])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await api.post('/ai/chat', { messages: newMessages }) as any
|
||||
setMessages([...newMessages, { role: 'assistant', content: res.data.reply }])
|
||||
} catch (err: any) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.response?.data?.error?.message || '请稍后重试'}` }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-sm whitespace-pre-wrap ${
|
||||
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 快捷问题 */}
|
||||
{messages.length <= 1 && (
|
||||
<div className="flex flex-wrap gap-2 pb-3">
|
||||
{QUICK_QUESTIONS.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => send(q)}
|
||||
className="px-3 py-1.5 text-sm rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
placeholder="输入问题..."
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button onClick={() => send()} disabled={loading || !input.trim()}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictTab() {
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchPrediction = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get('/ai/predict') as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrediction()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">AI 风险预测</h2>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button variant="secondary" size="sm" onClick={fetchPrediction} disabled={loading}>刷新预测</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!contractText.trim()) return
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
try {
|
||||
const res = await api.post('/ai/review', { contractText }) as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileSearch className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">合同审查</h2>
|
||||
</div>
|
||||
<Label>粘贴合同条款文本</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[200px] resize-y"
|
||||
placeholder="粘贴劳动合同文本..."
|
||||
value={contractText}
|
||||
onChange={(e) => setContractText(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Button onClick={handleReview} disabled={loading || !contractText.trim()}>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />审查中...</> : '开始审查'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">审查结果</h3>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CaseTab() {
|
||||
const [scenario, setScenario] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!scenario.trim()) return
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
try {
|
||||
const res = await api.post('/ai/match-case', { scenario }) as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Scale className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">案例匹配</h2>
|
||||
</div>
|
||||
<Label>描述你的争议情形</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[150px] resize-y"
|
||||
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
|
||||
value={scenario}
|
||||
onChange={(e) => setScenario(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Button onClick={handleMatch} disabled={loading || !scenario.trim()}>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />分析中...</> : '分析'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">分析结果</h3>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Calculator, Info, AlertCircle } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
|
||||
interface EmployeeOption {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
hireDate: string
|
||||
monthlySalary: number
|
||||
status: string
|
||||
contracts?: any[]
|
||||
}
|
||||
|
||||
function useEmployees() {
|
||||
return useQuery<EmployeeOption[]>({
|
||||
queryKey: ['roster-for-compensation'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function EmployeeSelector({ employees, selectedId, onSelect }: {
|
||||
employees?: EmployeeOption[]
|
||||
selectedId: string
|
||||
onSelect: (emp: EmployeeOption | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label>选择员工(自动带出入职日期和工资)</Label>
|
||||
<Select value={selectedId} onChange={(e) => {
|
||||
const emp = employees?.find((x) => x.id === e.target.value)
|
||||
onSelect(emp || null)
|
||||
}}>
|
||||
<option value="">-- 手动输入 --</option>
|
||||
{employees?.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.name}({emp.department})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Compensation() {
|
||||
const [tab, setTab] = useState<'severance' | 'double'>('severance')
|
||||
|
||||
const tabs: { key: typeof tab; label: string }[] = [
|
||||
{ key: 'severance', label: '经济补偿金' },
|
||||
{ key: 'double', label: '双倍工资' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">补偿计算</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'severance' && <SeveranceCalculator />}
|
||||
{tab === 'double' && <DoubleSalaryCalculator />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SeveranceCalculator() {
|
||||
const { data: employees } = useEmployees()
|
||||
const [selectedEmpId, setSelectedEmpId] = useState('')
|
||||
const [hireDate, setHireDate] = useState('')
|
||||
const [leaveDate, setLeaveDate] = useState('')
|
||||
const [avgWage, setAvgWage] = useState(8000)
|
||||
const [reason, setReason] = useState('negotiated')
|
||||
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
||||
const [result, setResult] = useState<any>(null)
|
||||
|
||||
const handleSelectEmp = (emp: EmployeeOption | null) => {
|
||||
setSelectedEmpId(emp?.id || '')
|
||||
if (emp) {
|
||||
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
|
||||
setAvgWage(emp.monthlySalary || 8000)
|
||||
}
|
||||
}
|
||||
|
||||
const reasonMap: Record<string, { label: string; multiplier: number; extra: string; illegal: boolean }> = {
|
||||
negotiated: { label: '协商一致解除', multiplier: 1, extra: '', illegal: false },
|
||||
fault: { label: '员工过错解除', multiplier: 0, extra: '员工过错解除,无需支付经济补偿金', illegal: false },
|
||||
nonfault: { label: '非过错解除', multiplier: 1, extra: '额外支付1个月代通知金', illegal: false },
|
||||
layoff: { label: '经济性裁员', multiplier: 1, extra: '', illegal: false },
|
||||
expired: { label: '合同到期不续签', multiplier: 1, extra: '用人单位不续签或降低条件续签', illegal: false },
|
||||
illegal: { label: '违法解除', multiplier: 2, extra: '违法解除劳动合同,按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)', illegal: true },
|
||||
}
|
||||
|
||||
const handleCalculate = () => {
|
||||
if (!hireDate || !leaveDate) return
|
||||
const hire = new Date(hireDate)
|
||||
const leave = new Date(leaveDate)
|
||||
const totalMonths = (leave.getFullYear() - hire.getFullYear()) * 12 + (leave.getMonth() - hire.getMonth())
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const remainingMonths = totalMonths % 12
|
||||
|
||||
let compMonths: number
|
||||
if (remainingMonths >= 6) compMonths = years + 1
|
||||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||||
else compMonths = years
|
||||
|
||||
if (compMonths <= 0) compMonths = 0.5
|
||||
|
||||
let wage = avgWage
|
||||
let capped = false
|
||||
if (socialAvgWage > 0 && avgWage > socialAvgWage * 3) {
|
||||
wage = socialAvgWage * 3
|
||||
compMonths = Math.min(compMonths, 12)
|
||||
capped = true
|
||||
}
|
||||
|
||||
const r = reasonMap[reason]
|
||||
const basePay = wage * compMonths
|
||||
let totalPay = basePay * r.multiplier
|
||||
let noticePay = 0
|
||||
if (reason === 'nonfault') {
|
||||
noticePay = wage
|
||||
totalPay += noticePay
|
||||
}
|
||||
|
||||
setResult({ years, remainingMonths, compMonths, wage, totalPay, basePay, totalMonths, capped, reason: r.label, reasonNote: r.extra, noticePay, noComp: r.multiplier === 0, isIllegal: r.illegal })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-4">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
<Label>入职日期</Label>
|
||||
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职日期</Label>
|
||||
<Input type="date" value={leaveDate} onChange={(e) => setLeaveDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>月平均工资(元)</Label>
|
||||
<Input type="number" value={avgWage} onChange={(e) => setAvgWage(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职原因</Label>
|
||||
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||
<option value="negotiated">协商一致解除</option>
|
||||
<option value="fault">员工过错解除</option>
|
||||
<option value="nonfault">非过错解除(额外1个月代通知金)</option>
|
||||
<option value="layoff">经济性裁员</option>
|
||||
<option value="expired">合同到期不续签</option>
|
||||
<option value="illegal">违法解除(赔偿金×2)</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>当地社平工资(选填)</Label>
|
||||
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
|
||||
</div>
|
||||
<Button onClick={handleCalculate} disabled={!hireDate || !leaveDate} className="w-full">
|
||||
<Calculator className="w-4 h-4 mr-1" /> 计算
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">离职原因:<span className="text-gray-900">{result.reason}</span></div>
|
||||
<div className="text-sm text-gray-500">工作年限:<span className="text-gray-900">{result.years}年{result.remainingMonths}个月</span></div>
|
||||
{result.noComp ? (
|
||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-sm">
|
||||
{result.reasonNote}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-gray-500">补偿月数:<span className="text-gray-900">{result.compMonths}个月</span></div>
|
||||
{result.capped && (
|
||||
<div className="text-sm text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">计算基数:<span className="text-gray-900">¥{result.wage.toLocaleString()}/月</span></div>
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.isIllegal ? '经济补偿金' : '应付金额'}</span>
|
||||
<span className="font-medium">¥{result.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
{result.isIllegal ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-danger">违法解除赔偿金(×2)</span>
|
||||
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{result.wage.toLocaleString()} × 2)</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.reason}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{result.wage.toLocaleString()})
|
||||
{result.noticePay > 0 && <span className="block">含代通知金 ¥{result.noticePay.toLocaleString()}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{result.reasonNote && (
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-sm ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{result.reasonNote}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>满1年补1个月,满6个月不满1年按1年算,不满6个月补半个月</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">填写信息后点击「计算」按钮</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DoubleSalaryCalculator() {
|
||||
const { data: employees } = useEmployees()
|
||||
const [selectedEmpId, setSelectedEmpId] = useState('')
|
||||
const [monthlyWage, setMonthlyWage] = useState(8000)
|
||||
const [hireDate, setHireDate] = useState('')
|
||||
const [hasContract, setHasContract] = useState(false)
|
||||
const [contractDate, setContractDate] = useState('')
|
||||
|
||||
const handleSelectEmp = (emp: EmployeeOption | null) => {
|
||||
setSelectedEmpId(emp?.id || '')
|
||||
if (emp) {
|
||||
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
|
||||
setMonthlyWage(emp.monthlySalary || 8000)
|
||||
const latestContract = emp.contracts?.find((c: any) => c.signDate)
|
||||
if (latestContract) {
|
||||
setHasContract(true)
|
||||
setContractDate(latestContract.signDate?.toString().slice(0, 10) || '')
|
||||
} else {
|
||||
setHasContract(false)
|
||||
setContractDate('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (!hireDate) return null
|
||||
const hire = new Date(hireDate)
|
||||
const startDate = new Date(hire)
|
||||
startDate.setMonth(startDate.getMonth() + 1)
|
||||
startDate.setDate(startDate.getDate() + 1)
|
||||
|
||||
let endDate = new Date(hire)
|
||||
endDate.setFullYear(endDate.getFullYear() + 1)
|
||||
|
||||
if (hasContract && contractDate) {
|
||||
const contract = new Date(contractDate)
|
||||
const daysDiff = Math.floor((contract.getTime() - hire.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (daysDiff > 30) {
|
||||
endDate = contract
|
||||
}
|
||||
}
|
||||
|
||||
const months = Math.min(
|
||||
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
|
||||
11,
|
||||
)
|
||||
const totalPay = monthlyWage * Math.max(months, 0)
|
||||
|
||||
return { startDate, endDate, months: Math.max(months, 0), totalPay }
|
||||
}, [monthlyWage, hireDate, hasContract, contractDate])
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-4">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
<Label>月工资(元)</Label>
|
||||
<Input type="number" value={monthlyWage} onChange={(e) => setMonthlyWage(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>入职日期</Label>
|
||||
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同签订情况</Label>
|
||||
<Select value={hasContract ? 'yes' : 'no'} onChange={(e) => setHasContract(e.target.value === 'yes')}>
|
||||
<option value="no">未签订</option>
|
||||
<option value="yes">已签订</option>
|
||||
</Select>
|
||||
</div>
|
||||
{hasContract && (
|
||||
<div>
|
||||
<Label>合同签订日期</Label>
|
||||
<Input type="date" value={contractDate} onChange={(e) => setContractDate(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">入职日期:<span className="text-gray-900">{hireDate}</span></div>
|
||||
<div className="text-sm text-gray-500">合同签订:<span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
|
||||
<div className="text-sm text-gray-500">双倍工资起算:<span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="text-sm text-gray-500">双倍工资截止:<span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">需赔</span>
|
||||
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">({result.months}个月 × ¥{monthlyWage.toLocaleString()})</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>法律规定:入职1个月没签合同,从第2个月起要付双倍工资,最多11个月</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">请填写入职日期</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Search, RefreshCw, Paperclip, Trash2, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
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 Signal from '../components/ui/Signal'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
|
||||
interface EmployeeItem {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
hireDate: string
|
||||
status: string
|
||||
contractStatus: string
|
||||
contractStatusText: string
|
||||
riskLevel: 'high' | 'medium' | 'low' | 'safe'
|
||||
isPregnant: boolean
|
||||
isInMedicalPeriod: boolean
|
||||
isWorkInjured: boolean
|
||||
}
|
||||
|
||||
interface EmployeeListResponse {
|
||||
items: EmployeeItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export default function Contracts() {
|
||||
const queryClient = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery<EmployeeListResponse>({
|
||||
queryKey: ['employees', search, page],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/employees', { params: { search, page, pageSize: 20 } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/employees', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setShowAddModal(false)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">合同管理</h1>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加员工
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索员工姓名或手机号"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 员工列表 */}
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无员工"
|
||||
description="点击「添加员工」开始管理合同"
|
||||
actionLabel="添加员工"
|
||||
onAction={() => setShowAddModal(true)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 px-3 font-medium">姓名</th>
|
||||
<th className="py-2 px-3 font-medium">部门</th>
|
||||
<th className="py-2 px-3 font-medium">入职日期</th>
|
||||
<th className="py-2 px-3 font-medium">合同状态</th>
|
||||
<th className="py-2 px-3 font-medium">特殊状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((emp) => (
|
||||
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setSelectedEmpId(emp.id)}>
|
||||
<td className="py-3 px-3 font-medium">{emp.name}</td>
|
||||
<td className="py-3 px-3 text-gray-600">{emp.department}</td>
|
||||
<td className="py-3 px-3 text-gray-600">{emp.hireDate}</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Signal level={emp.riskLevel} />
|
||||
<span>{emp.contractStatusText}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex gap-1">
|
||||
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600">孕期</span>}
|
||||
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600">医疗期</span>}
|
||||
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600">工伤</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>上一页</Button>
|
||||
<span className="text-sm text-gray-500">{page} / {data.totalPages}</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === data.totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 添加员工 Modal */}
|
||||
<AddEmployeeModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={(data) => addMutation.mutate(data)}
|
||||
loading={addMutation.isPending}
|
||||
error={addMutation.error as any}
|
||||
/>
|
||||
|
||||
{/* 员工详情抽屉 */}
|
||||
{selectedEmpId && (
|
||||
<EmployeeDetailDrawer employeeId={selectedEmpId} onClose={() => setSelectedEmpId(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
department: '',
|
||||
hireDate: '',
|
||||
monthlySalary: '',
|
||||
gender: '男' as '男' | '女',
|
||||
phone: '',
|
||||
isPregnant: false,
|
||||
isInMedicalPeriod: false,
|
||||
isWorkInjured: false,
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
contractYears: 3,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: any = {
|
||||
name: form.name,
|
||||
department: form.department,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary,
|
||||
gender: form.gender,
|
||||
phone: form.phone || undefined,
|
||||
isPregnant: form.isPregnant,
|
||||
isInMedicalPeriod: form.isInMedicalPeriod,
|
||||
isWorkInjured: form.isWorkInjured,
|
||||
}
|
||||
if (form.contractType !== 'UNSIGNED' && form.startDate) {
|
||||
data.contract = {
|
||||
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
|
||||
startDate: new Date(form.startDate).toISOString(),
|
||||
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
|
||||
contractType: form.contractType,
|
||||
contractYears: form.contractYears,
|
||||
probationMonths: form.probationMonths,
|
||||
probationSalary: form.probationSalary,
|
||||
}
|
||||
}
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="添加员工">
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
{error.response?.data?.error?.message || '操作失败'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>部门 *</Label>
|
||||
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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 className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>性别</Label>
|
||||
<Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}>
|
||||
<option value="男">男</option>
|
||||
<option value="女">女</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 特殊状态 */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
|
||||
孕期/哺乳期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
|
||||
医疗期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
|
||||
工伤
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 合同信息 */}
|
||||
<div className="border-t pt-3">
|
||||
<Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any })}>
|
||||
<option value="FIXED">固定期限</option>
|
||||
<option value="UNFIXED">无固定期限</option>
|
||||
<option value="UNSIGNED">未签合同</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.contractType !== 'UNSIGNED' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>签订日期</Label>
|
||||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同开始日期 *</Label>
|
||||
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>合同结束日期</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>试用期(月)</Label>
|
||||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>试用期工资</Label>
|
||||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.department || !form.hireDate || !form.monthlySalary}>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD')
|
||||
|
||||
const { data: employee } = useQuery<any>({
|
||||
queryKey: ['employee-detail', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/employees/${employeeId}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: attachments } = useQuery<any[]>({
|
||||
queryKey: ['employee-attachments', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/attachments/${employeeId}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const addAttachmentMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/attachments', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
|
||||
})
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
|
||||
})
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const fileUrl = event.target?.result as string
|
||||
addAttachmentMutation.mutate({
|
||||
employeeId,
|
||||
fileName: file.name,
|
||||
fileType,
|
||||
fileUrl,
|
||||
fileSize: file.size,
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const fileTypeLabels: Record<string, string> = {
|
||||
ID_CARD: '身份证',
|
||||
BANK_CARD: '银行卡',
|
||||
CONTRACT_SCAN: '合同扫描件',
|
||||
EDUCATION: '学历证书',
|
||||
OTHER: '其他',
|
||||
}
|
||||
|
||||
const emp = employee?.data || employee
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md bg-white h-full overflow-y-auto shadow-xl">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h3 className="font-medium text-gray-900">员工详情</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{emp && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{emp.name}</h2>
|
||||
<span className="text-sm text-gray-500">{emp.department}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><span className="text-gray-400">入职日期:</span>{emp.hireDate?.slice(0, 10)}</div>
|
||||
<div><span className="text-gray-400">性别:</span>{emp.gender || '-'}</div>
|
||||
<div><span className="text-gray-400">手机:</span>{emp.phone || '-'}</div>
|
||||
<div><span className="text-gray-400">状态:</span>{emp.status === 'ACTIVE' ? '在职' : '离职'}</div>
|
||||
</div>
|
||||
{(emp.isPregnant || emp.isInMedicalPeriod || emp.isWorkInjured) && (
|
||||
<div className="flex gap-1">
|
||||
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600">孕期</span>}
|
||||
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600">医疗期</span>}
|
||||
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600">工伤</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{emp.contracts && emp.contracts.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-sm mb-2">合同信息</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{emp.contracts.map((c: any) => (
|
||||
<div key={c.id} className="bg-gray-50 rounded p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Signal level={c.riskLevel || 'safe'} />
|
||||
<span>{c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'}</span>
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
{c.startDate?.slice(0, 10)} ~ {c.endDate?.slice(0, 10) || '无固定期限'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-sm flex items-center gap-1">
|
||||
<Paperclip className="w-4 h-4" /> 附件管理
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-sm">
|
||||
<option value="ID_CARD">身份证</option>
|
||||
<option value="BANK_CARD">银行卡</option>
|
||||
<option value="CONTRACT_SCAN">合同扫描件</option>
|
||||
<option value="EDUCATION">学历证书</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}
|
||||
>
|
||||
{addAttachmentMutation.isPending ? '上传中...' : '上传'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{attachments && attachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((att: any) => (
|
||||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{att.fileName}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteAttachmentMutation.mutate(att.id)}
|
||||
className="text-gray-400 hover:text-danger shrink-0 ml-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm text-center py-4">暂无附件</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Signal from '../components/ui/Signal'
|
||||
import type { DashboardData } from '../types'
|
||||
|
||||
function fmt(n: number) {
|
||||
return `¥${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'todos'>('overview')
|
||||
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
})
|
||||
|
||||
const ignoreMutation = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/ignore`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const stats = [
|
||||
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary' },
|
||||
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger' },
|
||||
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning' },
|
||||
{ label: '月加班费', value: fmt(data.stats.monthlyOvertimePay), icon: DollarSign, color: 'text-safe' },
|
||||
]
|
||||
|
||||
const payroll = data.payrollSummary
|
||||
const activities = data.monthlyActivities
|
||||
|
||||
const activityItems = [
|
||||
{ label: '新签合同', value: activities?.newContracts ?? 0, icon: FileText, color: 'text-primary' },
|
||||
{ label: '解聘人数', value: activities?.terminations ?? 0, icon: Users, color: 'text-danger' },
|
||||
{ label: '违纪处理', value: activities?.disciplinaryActions ?? 0, icon: AlertTriangle, color: 'text-warning' },
|
||||
{ label: '考勤记录', value: activities?.attendanceRecords ?? 0, icon: Calendar, color: 'text-gray-600' },
|
||||
{ label: '加班时长', value: `${activities?.overtimeHours ?? 0}h`, icon: TrendingUp, color: 'text-safe' },
|
||||
{ label: '加班费', value: fmt(activities?.overtimePay ?? 0), icon: DollarSign, color: 'text-safe' },
|
||||
]
|
||||
|
||||
const payrollItems = [
|
||||
{ label: '基本工资', value: payroll?.baseSalary ?? 0, icon: Wallet, color: 'text-gray-700' },
|
||||
{ label: '加班费', value: payroll?.overtimePay ?? 0, icon: TrendingUp, color: 'text-gray-700' },
|
||||
{ label: '津贴补贴', value: payroll?.allowance ?? 0, icon: Wallet, color: 'text-gray-700' },
|
||||
{ label: '扣款', value: -(payroll?.deduction ?? 0), icon: Wallet, color: 'text-danger' },
|
||||
]
|
||||
|
||||
const deductionItems = [
|
||||
{ label: '个人社保', value: -(payroll?.socialEmp ?? 0) },
|
||||
{ label: '个人公积金', value: -(payroll?.housingEmp ?? 0) },
|
||||
{ label: '个人所得税', value: -(payroll?.estimatedTax ?? 0) },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
|
||||
{ key: 'payroll' as const, label: '薪税', icon: Calculator, badge: payroll?.payslipCount ?? 0 },
|
||||
{ key: 'todos' as const, label: '待办', icon: ListTodo, badge: data.todos.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">{data.greeting}</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{payroll?.month} 月度总览</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
{tab.badge > 0 && (
|
||||
<span className={`ml-1 px-1.5 py-0.5 rounded-full text-xs ${activeTab === tab.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 概览 Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<Card key={stat.label} className="flex items-center gap-3">
|
||||
<Icon className={`w-8 h-8 ${stat.color}`} />
|
||||
<div>
|
||||
<div className="text-xl font-bold">{stat.value}</div>
|
||||
<div className="text-xs text-gray-500">{stat.label}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 本月工作动态 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold flex items-center gap-2"><Briefcase className="w-5 h-5" />本月工作动态</h2>
|
||||
<span className="text-sm text-gray-400">{activities?.month}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{activityItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.label} className="flex flex-col items-center p-3 rounded-lg bg-gray-50">
|
||||
<Icon className={`w-5 h-5 mb-1 ${item.color}`} />
|
||||
<div className="text-lg font-bold">{item.value}</div>
|
||||
<div className="text-xs text-gray-500">{item.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 风险分布 */}
|
||||
<Card>
|
||||
<h2 className="font-semibold mb-4">风险分布</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">{data.riskDistribution.contract}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">合同风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-warning">{data.riskDistribution.salary}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">薪资风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-danger">{data.riskDistribution.termination}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">解聘风险</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 薪税 Tab */}
|
||||
{activeTab === 'payroll' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold flex items-center gap-2"><Calculator className="w-5 h-5" />本月薪税费用总览</h2>
|
||||
<Link to="/money" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||
查看明细 <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{payroll && payroll.payslipCount > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* 工资构成 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600 mb-2">工资构成</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{payrollItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className={`w-4 h-4 ${item.color}`} />
|
||||
<span className="text-xs text-gray-500">{item.label}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 应发合计 */}
|
||||
<div className="flex items-center justify-between border-t border-b py-2">
|
||||
<span className="font-medium">应发合计</span>
|
||||
<span className="text-lg font-bold text-primary">{fmt(payroll.totalPay)}</span>
|
||||
</div>
|
||||
|
||||
{/* 扣减项 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600 mb-2">扣减项</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{deductionItems.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-red-50">
|
||||
<span className="text-xs text-gray-500">{item.label}</span>
|
||||
<span className="text-sm font-medium text-danger">{fmt(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 员工实发 */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" />员工实发工资</span>
|
||||
<span className="text-lg font-bold text-safe">{fmt(payroll.empNetPay)}</span>
|
||||
</div>
|
||||
|
||||
{/* 企业成本 */}
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<div className="text-sm font-medium text-gray-600 mb-1">企业用工成本</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-blue-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" />企业社保</span>
|
||||
<span className="text-sm font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-purple-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" />企业公积金</span>
|
||||
<span className="text-sm font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-green-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Receipt className="w-3 h-3" />工资总额</span>
|
||||
<span className="text-sm font-medium text-green-700">{fmt(payroll.totalPay)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{payroll.severancePay > 0 && (
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-orange-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><DollarSign className="w-3 h-3" />经济补偿金</span>
|
||||
<span className="text-sm font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="font-medium flex items-center gap-2"><DollarSign className="w-4 h-4 text-danger" />企业总成本</span>
|
||||
<span className="text-lg font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工资条确认状态 */}
|
||||
<div className="flex items-center gap-4 text-sm border-t pt-3">
|
||||
<span className="text-gray-500">工资条确认:</span>
|
||||
<span className="text-safe">已确认 {payroll.confirmedPayslips}</span>
|
||||
<span className="text-warning">未确认 {payroll.unconfirmedPayslips}</span>
|
||||
<span className="text-gray-400">共 {payroll.payslipCount} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="本月暂无工资数据" description="请先在薪税页面生成本月工资条" />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 待办 Tab */}
|
||||
{activeTab === 'todos' && (
|
||||
<div className="space-y-4">
|
||||
{/* 待办列表 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">待办事项</h2>
|
||||
<span className="text-sm text-gray-400">{data.todos.length} 项</span>
|
||||
</div>
|
||||
|
||||
{data.todos.length === 0 ? (
|
||||
<EmptyState title="暂无待办" description="所有风险项已处理完毕" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.todos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-3 py-3 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
|
||||
<Signal level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => resolveMutation.mutate(todo.id)}
|
||||
disabled={resolveMutation.isPending}
|
||||
className="p-1.5 rounded hover:bg-safe/10 text-safe"
|
||||
title="标记完成"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => ignoreMutation.mutate(todo.id)}
|
||||
disabled={ignoreMutation.isPending}
|
||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-400"
|
||||
title="忽略"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 已办事项 */}
|
||||
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold flex items-center gap-2"><CheckSquare className="w-5 h-5 text-safe" />已办事项</h2>
|
||||
<span className="text-sm text-gray-400">{data.resolvedTodos.length} 项</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.resolvedTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
|
||||
<Check className="w-4 h-4 text-safe" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-600 line-through">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400">{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<span className="text-xs text-gray-400">
|
||||
{todo.resolvedAt ? new Date(todo.resolvedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Calculator, AlertCircle, Info, Save, Check, Upload, Zap, Bell } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
|
||||
type Tab = 'overtime' | 'social' | 'payslip'
|
||||
|
||||
export default function Money() {
|
||||
const [tab, setTab] = useState<Tab>('overtime')
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'overtime', label: '加班费计算' },
|
||||
{ key: 'social', label: '社保公积金' },
|
||||
{ key: 'payslip', label: '工资条管理' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">薪税计算</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overtime' && <OvertimeCalculator />}
|
||||
{tab === 'social' && <SocialInsuranceCalculator />}
|
||||
{tab === 'payslip' && <PayslipManager />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OvertimeCalculator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [monthlyWage, setMonthlyWage] = useState(8000)
|
||||
const [weekdayHours, setWeekdayHours] = useState(0)
|
||||
const [weekendHours, setWeekendHours] = useState(0)
|
||||
const [holidayHours, setHolidayHours] = useState(0)
|
||||
const [selectedEmployee, setSelectedEmployee] = useState('')
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string; monthlySalary: number }[] }>({
|
||||
queryKey: ['employees-for-overtime'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll/overtime', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
|
||||
alert('加班费记录已保存')
|
||||
},
|
||||
})
|
||||
|
||||
const batchImportMutation = useMutation({
|
||||
mutationFn: (data: any[]) => api.post('/payroll/overtime/batch', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
|
||||
alert('批量导入成功')
|
||||
},
|
||||
})
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string
|
||||
const lines = text.split('\n').filter(l => l.trim())
|
||||
const items: any[] = []
|
||||
const empList = employees?.items || []
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(',').map(c => c.trim())
|
||||
const empName = cols[0]
|
||||
const emp = empList.find(e => e.name === empName)
|
||||
if (!emp) continue
|
||||
const salary = Number(emp.monthlySalary) || 8000
|
||||
items.push({
|
||||
employeeId: emp.id,
|
||||
month: cols[4] || month,
|
||||
monthlyWage: salary,
|
||||
weekdayHours: Number(cols[1]) || 0,
|
||||
weekendHours: Number(cols[2]) || 0,
|
||||
holidayHours: Number(cols[3]) || 0,
|
||||
})
|
||||
}
|
||||
if (items.length > 0) {
|
||||
batchImportMutation.mutate(items)
|
||||
} else {
|
||||
alert('未匹配到员工,请确保CSV第一列为员工姓名')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const result = useMemo(() => {
|
||||
const hourlyWage = monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * holidayHours
|
||||
const total = weekdayPay + weekendPay + holidayPay
|
||||
const totalHours = weekdayHours + weekendHours + holidayHours
|
||||
return { hourlyWage, weekdayPay, weekendPay, holidayPay, total, totalHours }
|
||||
}, [monthlyWage, weekdayHours, weekendHours, holidayHours])
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>关联员工(选填,自动填入月工资)</Label>
|
||||
<Select value={selectedEmployee} onChange={(e) => {
|
||||
setSelectedEmployee(e.target.value)
|
||||
const emp = employees?.items.find((x) => x.id === e.target.value)
|
||||
if (emp) {
|
||||
const salary = Number(emp.monthlySalary) || 8000
|
||||
setMonthlyWage(salary)
|
||||
}
|
||||
}}>
|
||||
<option value="">不关联员工</option>
|
||||
{employees?.items.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>月份</Label>
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>月工资(元)</Label>
|
||||
<Input type="number" value={monthlyWage} onChange={(e) => setMonthlyWage(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>工作日加班(小时)</Label>
|
||||
<Input type="number" value={weekdayHours} onChange={(e) => setWeekdayHours(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>休息日加班(小时)</Label>
|
||||
<Input type="number" value={weekendHours} onChange={(e) => setWeekendHours(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>法定节假日加班(小时)</Label>
|
||||
<Input type="number" value={holidayHours} onChange={(e) => setHolidayHours(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">小时工资:<span className="text-gray-900 font-medium">¥{result.hourlyWage.toFixed(2)}</span></div>
|
||||
<div className="space-y-2">
|
||||
<ResultRow label={`工作日 ${weekdayHours}h × 1.5`} value={result.weekdayPay} />
|
||||
<ResultRow label={`休息日 ${weekendHours}h × 2.0`} value={result.weekendPay} />
|
||||
<ResultRow label={`节假日 ${holidayHours}h × 3.0`} value={result.holidayPay} />
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">合计</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.total.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
{result.totalHours > 36 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
月加班{result.totalHours}小时,超过36小时上限
|
||||
</div>
|
||||
)}
|
||||
{result.totalHours > 0 && result.totalHours <= 36 && (
|
||||
<div className="text-sm text-safe">月加班{result.totalHours}小时,未超36小时上限 ✅</div>
|
||||
)}
|
||||
{selectedEmployee && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => saveMutation.mutate({
|
||||
employeeId: selectedEmployee,
|
||||
month,
|
||||
monthlyWage,
|
||||
weekdayHours,
|
||||
weekendHours,
|
||||
holidayHours,
|
||||
})}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{saveMutation.isPending ? '保存中...' : '保存加班费记录'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="border-t pt-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={batchImportMutation.isPending}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
{batchImportMutation.isPending ? '导入中...' : '批量导入加班数据(CSV)'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
CSV格式:姓名,工作日加班,休息日加班,节假日加班,月份
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultRow({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">{label}</span>
|
||||
<span className="font-medium">¥{value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PayslipManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
employeeId: '',
|
||||
baseSalary: 8000,
|
||||
allowance: 0,
|
||||
deduction: 0,
|
||||
})
|
||||
|
||||
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
|
||||
queryKey: ['employees-for-payslip'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: payslips, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['payslips', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/payroll/payslip', { params: { month } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll/payslip/generate', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payslips'] })
|
||||
setShowCreate(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/payroll/payslip/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
|
||||
})
|
||||
|
||||
const batchGenerateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll/payslip/batch-generate', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payslips'] })
|
||||
alert('批量生成完成')
|
||||
},
|
||||
})
|
||||
|
||||
const confirmedCount = payslips?.filter((p: any) => p.confirmedAt).length || 0
|
||||
const unconfirmedCount = payslips ? payslips.length - confirmedCount : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-40" />
|
||||
{payslips && payslips.length > 0 && (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>生成工资条</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => batchGenerateMutation.mutate({ month })}
|
||||
disabled={batchGenerateMutation.isPending}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
{batchGenerateMutation.isPending ? '生成中...' : '一键全员生成'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">生成工资条</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={createForm.employeeId} onChange={(e) => setCreateForm({ ...createForm, employeeId: e.target.value })}>
|
||||
<option value="">请选择</option>
|
||||
{employees?.items.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>基本工资</Label>
|
||||
<Input type="number" value={createForm.baseSalary} onChange={(e) => setCreateForm({ ...createForm, baseSalary: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>津贴</Label>
|
||||
<Input type="number" value={createForm.allowance} onChange={(e) => setCreateForm({ ...createForm, allowance: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>扣款</Label>
|
||||
<Input type="number" value={createForm.deduction} onChange={(e) => setCreateForm({ ...createForm, deduction: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
onClick={() => generateMutation.mutate({
|
||||
employeeId: createForm.employeeId,
|
||||
month,
|
||||
baseSalary: createForm.baseSalary,
|
||||
allowance: createForm.allowance,
|
||||
deduction: createForm.deduction,
|
||||
})}
|
||||
disabled={!createForm.employeeId || generateMutation.isPending}
|
||||
>
|
||||
{generateMutation.isPending ? '生成中...' : '确认生成(自动关联加班费)'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !payslips || payslips.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">该月份暂无工资条记录</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2">员工</th>
|
||||
<th className="py-2">部门</th>
|
||||
<th className="py-2 text-right">基本工资</th>
|
||||
<th className="py-2 text-right">加班费</th>
|
||||
<th className="py-2 text-right">津贴</th>
|
||||
<th className="py-2 text-right">扣款</th>
|
||||
<th className="py-2 text-right">应发合计</th>
|
||||
<th className="py-2 text-center">确认状态</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payslips.map((p: any) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2">{p.employee?.name}</td>
|
||||
<td className="py-2 text-gray-500">{p.employee?.department}</td>
|
||||
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '¥0'}</td>
|
||||
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-center">
|
||||
{p.confirmedAt ? (
|
||||
<span className="inline-flex items-center gap-1 text-safe text-xs">
|
||||
<Check className="w-3 h-3" />已确认
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-warning text-xs">未确认</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(p.id)}
|
||||
className="text-xs text-gray-400 hover:text-danger"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SocialInsuranceCalculator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [base, setBase] = useState(8000)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [configForm, setConfigForm] = useState<any>({})
|
||||
|
||||
const { data: config } = useQuery<any>({
|
||||
queryKey: ['social-config'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/social/calculate', { base }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: (data: any) => api.put('/social/config', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['social-config'] }),
|
||||
})
|
||||
|
||||
useMemo(() => {
|
||||
if (config) setConfigForm(config)
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium">社保公积金计算</h2>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowConfig(!showConfig)}>
|
||||
{showConfig ? '收起配置' : '配置比例'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showConfig && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-4 text-sm">社保比例配置({config?.city || '北京'})</h3>
|
||||
<div className="grid md:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<Label>城市</Label>
|
||||
<Input value={configForm.city || ''} onChange={(e) => setConfigForm({ ...configForm, city: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>养老(企业%)</Label>
|
||||
<Input type="number" value={configForm.pensionOrg || 0} onChange={(e) => setConfigForm({ ...configForm, pensionOrg: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>养老(个人%)</Label>
|
||||
<Input type="number" value={configForm.pensionEmp || 0} onChange={(e) => setConfigForm({ ...configForm, pensionEmp: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>医疗(企业%)</Label>
|
||||
<Input type="number" value={configForm.medicalOrg || 0} onChange={(e) => setConfigForm({ ...configForm, medicalOrg: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>医疗(个人%)</Label>
|
||||
<Input type="number" value={configForm.medicalEmp || 0} onChange={(e) => setConfigForm({ ...configForm, medicalEmp: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>失业(企业%)</Label>
|
||||
<Input type="number" value={configForm.unemploymentOrg || 0} onChange={(e) => setConfigForm({ ...configForm, unemploymentOrg: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>失业(个人%)</Label>
|
||||
<Input type="number" value={configForm.unemploymentEmp || 0} onChange={(e) => setConfigForm({ ...configForm, unemploymentEmp: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>工伤(企业%)</Label>
|
||||
<Input type="number" value={configForm.injuryOrg || 0} onChange={(e) => setConfigForm({ ...configForm, injuryOrg: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>生育(企业%)</Label>
|
||||
<Input type="number" value={configForm.maternityOrg || 0} onChange={(e) => setConfigForm({ ...configForm, maternityOrg: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金(企业%)</Label>
|
||||
<Input type="number" value={configForm.housingOrg || 0} onChange={(e) => setConfigForm({ ...configForm, housingOrg: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金(个人%)</Label>
|
||||
<Input type="number" value={configForm.housingEmp || 0} onChange={(e) => setConfigForm({ ...configForm, housingEmp: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>缴费基数下限</Label>
|
||||
<Input type="number" value={configForm.baseMin || 0} onChange={(e) => setConfigForm({ ...configForm, baseMin: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>缴费基数上限</Label>
|
||||
<Input type="number" value={configForm.baseMax || 0} onChange={(e) => setConfigForm({ ...configForm, baseMax: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button size="sm" onClick={() => updateConfigMutation.mutate(configForm)} disabled={updateConfigMutation.isPending}>
|
||||
{updateConfigMutation.isPending ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>缴费基数(月工资)</Label>
|
||||
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<Button onClick={() => calcMutate()} disabled={isPending}>
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
{isPending ? '计算中...' : '开始计算'}
|
||||
</Button>
|
||||
{config && (
|
||||
<div className="text-xs text-gray-400">
|
||||
当前配置:{config.city} | 基数范围 {config.baseMin}~{config.baseMax}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{result.actualBase.toLocaleString()}</span>
|
||||
{result.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{result.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-1.5">险种</th>
|
||||
<th className="py-1.5 text-right">企业%</th>
|
||||
<th className="py-1.5 text-right">个人%</th>
|
||||
<th className="py-1.5 text-right">企业缴纳</th>
|
||||
<th className="py-1.5 text-right">个人缴纳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map((item: any) => (
|
||||
<tr key={item.name} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
|
||||
<td className="py-1.5 text-right">¥{item.orgAmount.toFixed(2)}</td>
|
||||
<td className="py-1.5 text-right">¥{item.empAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-bold">
|
||||
<td className="py-2" colSpan={3}>合计</td>
|
||||
<td className="py-2 text-right text-danger">¥{result.totalOrg.toFixed(2)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{result.totalEmp.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-xl font-bold text-primary">¥{result.total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{result.totalOrg.toFixed(2)} + 个人承担 ¥{result.totalEmp.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">点击「开始计算」查看结果</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,372 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
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'
|
||||
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications'>('org')
|
||||
|
||||
const { data: orgData } = useQuery<any>({
|
||||
queryKey: ['org-settings'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/settings/org') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: usersData } = useQuery<any>({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/settings/users') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const updateOrgMutation = useMutation({
|
||||
mutationFn: (data: any) => api.put('/settings/org', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['org-settings'] }),
|
||||
})
|
||||
|
||||
const sections = [
|
||||
{ key: 'org' as const, label: '企业信息', icon: Building2 },
|
||||
{ key: 'users' as const, label: '用户管理', icon: Users },
|
||||
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
|
||||
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">系统设置</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{sections.map((s) => {
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setActiveSection(s.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{s.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeSection === 'org' && (
|
||||
<OrgSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} saving={updateOrgMutation.isPending} />
|
||||
)}
|
||||
{activeSection === 'users' && <UserSettings usersData={usersData} />}
|
||||
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
|
||||
{activeSection === 'notifications' && <NotificationSettings />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
|
||||
const [form, setForm] = useState({
|
||||
name: orgData?.data?.name || '',
|
||||
contactName: orgData?.data?.contactName || '',
|
||||
contactPhone: orgData?.data?.contactPhone || '',
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">企业信息</h2>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<Label>企业名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="企业名称" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系人</Label>
|
||||
<Input value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} placeholder="联系人姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系电话</Label>
|
||||
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
|
||||
</div>
|
||||
<Button onClick={() => onSave(form)} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function UserSettings({ usersData }: { usersData: any }) {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const users = usersData?.data || []
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-medium">用户管理</h2>
|
||||
<Button size="sm" onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加用户
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 px-3 font-medium">姓名</th>
|
||||
<th className="py-2 px-3 font-medium">手机号</th>
|
||||
<th className="py-2 px-3 font-medium">角色</th>
|
||||
<th className="py-2 px-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u: any) => (
|
||||
<tr key={u.id} className="border-b last:border-0">
|
||||
<td className="py-3 px-3 font-medium">{u.name}</td>
|
||||
<td className="py-3 px-3 text-gray-600">{u.phone}</td>
|
||||
<td className="py-3 px-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
|
||||
{u.role === 'ADMIN' ? '管理员' : u.role === 'HR' ? 'HR' : '查看者'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-safe">正常</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AddUserModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [form, setForm] = useState({ name: '', phone: '', password: '', role: 'HR' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.post('/settings/users', form)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '添加失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="添加用户">
|
||||
<div className="space-y-4">
|
||||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号 *</Label>
|
||||
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>初始密码 *</Label>
|
||||
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>角色</Label>
|
||||
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="HR">HR</option>
|
||||
<option value="ADMIN">管理员</option>
|
||||
<option value="VIEWER">查看者</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.password}>
|
||||
{loading ? '添加中...' : '添加'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanSettings({ orgData }: { orgData: any }) {
|
||||
const plan = orgData?.data?.plan || 'FREE'
|
||||
const maxEmployees = orgData?.data?.maxEmployees || 10
|
||||
|
||||
const plans = [
|
||||
{ key: 'FREE', label: '免费版', price: '¥0/月', features: ['10人以内', '基础风险检测', '10次AI问答/月'] },
|
||||
{ key: 'PRO', label: '专业版', price: '¥299/月', features: ['100人以内', '全功能风险检测', '100次AI问答/月', '合同审查'] },
|
||||
{ key: 'ENTERPRISE', label: '企业版', price: '联系客服', features: ['无限人数', '无限AI问答', '专属客服', 'API接入'] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{plans.map((p) => (
|
||||
<Card key={p.key}>
|
||||
<div className={`px-4 py-3 rounded-t-lg ${plan === p.key ? 'bg-primary text-white' : 'bg-gray-50'}`}>
|
||||
<div className="font-medium">{p.label}</div>
|
||||
<div className={`text-lg font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{p.features.map((f, i) => (
|
||||
<div key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="text-safe">✓</span> {f}
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
{plan === p.key ? (
|
||||
<div className="text-sm text-center text-primary font-medium">当前套餐</div>
|
||||
) : (
|
||||
<Button variant="secondary" className="w-full" size="sm">升级</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState<any>({})
|
||||
const [checkResult, setCheckResult] = useState<string>('')
|
||||
|
||||
const { data: setting } = useQuery<any>({
|
||||
queryKey: ['notification-settings'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/notifications/settings') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: logsData } = useQuery<any>({
|
||||
queryKey: ['notification-logs'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/notifications/logs', { params: { pageSize: 10 } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
useMemo(() => {
|
||||
if (setting) setForm(setting)
|
||||
}, [setting])
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.put('/notifications/settings', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
|
||||
})
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => api.post('/notifications/check-contracts') as any,
|
||||
onSuccess: (res: any) => {
|
||||
setCheckResult(`检查完成:发现 ${res.data.checked} 个即将到期的合同,已发送 ${res.data.notified} 条通知`)
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
|
||||
},
|
||||
})
|
||||
|
||||
const logs = logsData?.items || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">通知设置</h2>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">合同到期提醒</span>
|
||||
<input type="checkbox" checked={form.contractExpiry ?? true} onChange={(e) => setForm({ ...form, contractExpiry: e.target.checked })} />
|
||||
</label>
|
||||
<div>
|
||||
<Label>提前提醒天数</Label>
|
||||
<Input type="number" value={form.expiryDays ?? 30} onChange={(e) => setForm({ ...form, expiryDays: Number(e.target.value) })} />
|
||||
</div>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">未签合同提醒</span>
|
||||
<input type="checkbox" checked={form.contractUnsigned ?? true} onChange={(e) => setForm({ ...form, contractUnsigned: e.target.checked })} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">加班超时提醒</span>
|
||||
<input type="checkbox" checked={form.overtimeAlert ?? true} onChange={(e) => setForm({ ...form, overtimeAlert: e.target.checked })} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">工资条发布通知</span>
|
||||
<input type="checkbox" checked={form.payslipReady ?? true} onChange={(e) => setForm({ ...form, payslipReady: e.target.checked })} />
|
||||
</label>
|
||||
<div className="border-t pt-3 space-y-3">
|
||||
<div className="text-sm font-medium">月度事务提醒</div>
|
||||
<div className="text-xs text-gray-400">设置每月截止日,到期后自动生成待办提醒</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>
|
||||
<Label>社保缴纳日</Label>
|
||||
<Input type="number" min={1} max={28} value={form.socialInsDay ?? 15} onChange={(e) => setForm({ ...form, socialInsDay: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴纳日</Label>
|
||||
<Input type="number" min={1} max={28} value={form.housingFundDay ?? 15} onChange={(e) => setForm({ ...form, housingFundDay: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>个税申报日</Label>
|
||||
<Input type="number" min={1} max={28} value={form.taxDay ?? 15} onChange={(e) => setForm({ ...form, taxDay: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>企业微信 Webhook(选填)</Label>
|
||||
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||
</div>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">邮件通知</span>
|
||||
<input type="checkbox" checked={form.emailNotify ?? false} onChange={(e) => setForm({ ...form, emailNotify: e.target.checked })} />
|
||||
</label>
|
||||
{form.emailNotify && (
|
||||
<div>
|
||||
<Label>通知邮箱</Label>
|
||||
<Input value={form.email || ''} onChange={(e) => setForm({ ...form, email: e.target.value || null })} placeholder="hr@example.com" />
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => updateMutation.mutate(form)} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? '保存中...' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="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-sm mb-3">{checkResult}</div>
|
||||
)}
|
||||
{logs.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log: any) => (
|
||||
<div key={log.id} className="text-sm 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-400 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm text-center py-4">暂无通知记录</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Signal from '../components/ui/Signal'
|
||||
|
||||
const REASONS = [
|
||||
{ value: 'NEGOTIATED', label: '协商解除(双方同意分开了)', legalBasis: '《劳动合同法》第36条' },
|
||||
{ value: 'FAULT', label: '员工犯错被辞退(严重违纪/失职等)', legalBasis: '《劳动合同法》第39条' },
|
||||
{ value: 'NONFAULT', label: '员工没犯错但干不了(生病/不胜任等)', legalBasis: '《劳动合同法》第40条' },
|
||||
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
|
||||
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
|
||||
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2)', legalBasis: '《劳动合同法》第87条' },
|
||||
]
|
||||
|
||||
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '确认完成']
|
||||
|
||||
interface RosterEmployee {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
status: string
|
||||
hireDate: string
|
||||
monthlySalary: number
|
||||
latestContract: any
|
||||
counts: any
|
||||
}
|
||||
|
||||
interface EmployeeProfile {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
status: string
|
||||
hireDate: string
|
||||
monthlySalary: number
|
||||
isPregnant: boolean
|
||||
isInMedicalPeriod: boolean
|
||||
isWorkInjured: boolean
|
||||
contracts: any[]
|
||||
disciplinaryRecords: any[]
|
||||
attendanceRecords: any[]
|
||||
performanceRecords: any[]
|
||||
trainingRecords: any[]
|
||||
}
|
||||
|
||||
export default function Termination() {
|
||||
const queryClient = useQueryClient()
|
||||
const [step, setStep] = useState(0)
|
||||
const [reason, setReason] = useState('')
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
const [terminationDate, setTerminationDate] = useState('')
|
||||
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
|
||||
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
|
||||
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
||||
|
||||
const { data: employees } = useQuery<RosterEmployee[]>({
|
||||
queryKey: ['roster-for-termination'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const selectedEmployee = employees?.find((e) => e.id === employeeId)
|
||||
|
||||
const { data: profile } = useQuery<EmployeeProfile>({
|
||||
queryKey: ['employee-profile', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/roster/${employeeId}/profile`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!employeeId,
|
||||
})
|
||||
|
||||
// 根据员工数据生成解聘建议
|
||||
const suggestions = useMemo(() => {
|
||||
if (!profile) return []
|
||||
const list: { reason: string; label: string; why: string }[] = []
|
||||
|
||||
// 有违纪记录 → 建议过错解除
|
||||
if (profile.disciplinaryRecords?.length > 0) {
|
||||
const severe = profile.disciplinaryRecords.filter((d) => d.action === 'TERMINATION' || d.type === 'INSUBORDINATION' || d.type === 'MISCONDUCT')
|
||||
if (severe.length > 0) {
|
||||
list.push({ reason: 'FAULT', label: '过错解除', why: `有${severe.length}条严重违纪记录,可依据规章制度解除` })
|
||||
} else {
|
||||
list.push({ reason: 'FAULT', label: '过错解除', why: `有${profile.disciplinaryRecords.length}条违纪记录,可考虑过错解除` })
|
||||
}
|
||||
}
|
||||
|
||||
// 绩效不佳 → 建议非过错解除
|
||||
const badPerf = profile.performanceRecords?.filter((p) => p.result === 'NEED_IMPROVE' || p.result === 'UNQUALIFIED')
|
||||
if (badPerf?.length > 0) {
|
||||
const hasTraining = profile.trainingRecords?.length > 0
|
||||
list.push({
|
||||
reason: 'NONFAULT',
|
||||
label: '非过错解除',
|
||||
why: hasTraining
|
||||
? `有${badPerf.length}次绩效不佳且已培训/调岗,可按不胜任解除`
|
||||
: `有${badPerf.length}次绩效不佳,需先培训或调岗后才能按不胜任解除`,
|
||||
})
|
||||
}
|
||||
|
||||
// 合同到期 → 建议不续签
|
||||
const latestContract = profile.contracts?.[0]
|
||||
if (latestContract?.endDate) {
|
||||
const daysToExpire = Math.floor((new Date(latestContract.endDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (daysToExpire <= 30 && daysToExpire >= -90) {
|
||||
list.push({ reason: 'EXPIRED', label: '合同到期不续签', why: `合同将于${latestContract.endDate.slice(0, 10)}到期,可选择不续签` })
|
||||
}
|
||||
}
|
||||
|
||||
// 未签合同 → 提示双倍工资风险
|
||||
if (!latestContract?.signDate || latestContract?.contractType === 'UNSIGNED') {
|
||||
const days = Math.floor((new Date().getTime() - new Date(profile.hireDate).getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (days > 30) {
|
||||
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: `未签合同已${days}天,协商解除可同时解决双倍工资问题` })
|
||||
}
|
||||
}
|
||||
|
||||
// 孕期/哺乳期/工伤 → 风险提示
|
||||
if (profile.isPregnant) list.push({ reason: '', label: '⚠️ 孕期禁止解除', why: '该员工在孕期/哺乳期,法律禁止以非过错理由解除' })
|
||||
if (profile.isWorkInjured) list.push({ reason: '', label: '⚠️ 工伤期间禁止解除', why: '工伤期间不得解除劳动合同' })
|
||||
if (profile.isInMedicalPeriod) list.push({ reason: '', label: '⚠️ 医疗期保护', why: '医疗期内不得以非过错理由解除' })
|
||||
|
||||
// 默认推荐协商解除
|
||||
if (list.length === 0 || !list.some((s) => s.reason !== '')) {
|
||||
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: '无特殊风险因素,推荐协商解除,成本最低、风险最小' })
|
||||
}
|
||||
|
||||
return list
|
||||
}, [profile])
|
||||
|
||||
const { data: checklistItems } = useQuery<{ key: string; label: string }[]>({
|
||||
queryKey: ['checklist', reason],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/termination/checklist/${reason}`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!reason && step >= 2,
|
||||
})
|
||||
|
||||
const { data: riskAssessment } = useQuery<{ level: string; warnings: string[] }>({
|
||||
queryKey: ['assess', employeeId, reason],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/termination/assess/${employeeId}`, { params: { reason } }) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!employeeId && !!reason && step >= 1,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/termination', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
setStep(4)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: evidenceChain } = useQuery({
|
||||
queryKey: ['evidence-chain', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!employeeId && step === 4 && saveMutation.isSuccess,
|
||||
})
|
||||
|
||||
const reasonLabel = REASONS.find((r) => r.value === reason)?.label || ''
|
||||
const reasonLegalBasis = REASONS.find((r) => r.value === reason)?.legalBasis || ''
|
||||
|
||||
const costResult = useMemo(() => {
|
||||
if (!selectedEmployee || !terminationDate) return null
|
||||
const hire = new Date(selectedEmployee.hireDate)
|
||||
const leave = new Date(terminationDate)
|
||||
const totalMonths = (leave.getFullYear() - hire.getFullYear()) * 12 + (leave.getMonth() - hire.getMonth())
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const remainingMonths = totalMonths % 12
|
||||
let compMonths: number
|
||||
if (remainingMonths >= 6) compMonths = years + 1
|
||||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||||
else compMonths = years
|
||||
if (compMonths <= 0) compMonths = 0.5
|
||||
|
||||
const wage = selectedEmployee.monthlySalary || 0
|
||||
let capped = false
|
||||
let cappedWage = wage
|
||||
let cappedMonths = compMonths
|
||||
if (socialAvgWage > 0 && wage > socialAvgWage * 3) {
|
||||
cappedWage = socialAvgWage * 3
|
||||
cappedMonths = Math.min(compMonths, 12)
|
||||
capped = true
|
||||
}
|
||||
|
||||
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
|
||||
NEGOTIATED: { multiplier: 1, notice: false },
|
||||
FAULT: { multiplier: 0, notice: false },
|
||||
NONFAULT: { multiplier: 1, notice: true },
|
||||
LAYOFF: { multiplier: 1, notice: false },
|
||||
EXPIRED: { multiplier: 1, notice: false },
|
||||
ILLEGAL: { multiplier: 2, notice: false },
|
||||
}
|
||||
const r = reasonMap[reason] || { multiplier: 1, notice: false }
|
||||
const basePay = cappedWage * cappedMonths
|
||||
const severancePay = basePay * r.multiplier
|
||||
const noticePay = r.notice ? cappedWage : 0
|
||||
const totalSeverance = severancePay + noticePay
|
||||
|
||||
// 双倍工资计算(未签合同)
|
||||
const contract = selectedEmployee.latestContract
|
||||
const hasContract = contract && contract.signDate && contract.contractType !== 'UNSIGNED'
|
||||
let doublePay = 0
|
||||
let doubleMonths = 0
|
||||
let doubleStartDate = ''
|
||||
let doubleEndDate = ''
|
||||
if (!hasContract) {
|
||||
const startDate = new Date(hire)
|
||||
startDate.setMonth(startDate.getMonth() + 1)
|
||||
startDate.setDate(startDate.getDate() + 1)
|
||||
let endDate = new Date(hire)
|
||||
endDate.setFullYear(endDate.getFullYear() + 1)
|
||||
if (leave < endDate) endDate = leave
|
||||
doubleMonths = Math.min(
|
||||
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
|
||||
11,
|
||||
)
|
||||
doubleMonths = Math.max(doubleMonths, 0)
|
||||
doublePay = wage * doubleMonths
|
||||
doubleStartDate = startDate.toISOString().slice(0, 10)
|
||||
doubleEndDate = endDate.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
return {
|
||||
years, remainingMonths, compMonths, wage, cappedWage, cappedMonths, capped,
|
||||
basePay, severancePay, noticePay, totalSeverance,
|
||||
doublePay, doubleMonths, doubleStartDate, doubleEndDate, hasContract,
|
||||
noComp: r.multiplier === 0,
|
||||
isIllegal: r.multiplier === 2,
|
||||
grandTotal: totalSeverance + doublePay,
|
||||
}
|
||||
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
|
||||
|
||||
const canProceed = () => {
|
||||
if (step === 0) return !!employeeId
|
||||
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
||||
if (step === 2) return true
|
||||
if (step === 3) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate({
|
||||
employeeId,
|
||||
reason,
|
||||
terminationDate: new Date(terminationDate).toISOString(),
|
||||
compensation: costResult?.totalSeverance || 0,
|
||||
checklist,
|
||||
remark: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setStep(0)
|
||||
setReason('')
|
||||
setEmployeeId('')
|
||||
setTerminationDate('')
|
||||
setChecklist({})
|
||||
setAcknowledgeRisk(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">解聘助手</h1>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${i <= step ? 'bg-primary' : 'bg-gray-300'}`} />
|
||||
{i < STEPS.length - 1 && <div className={`w-8 h-0.5 ${i < step ? 'bg-primary' : 'bg-gray-300'}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-2 text-sm text-gray-500">Step {step + 1}/5:{STEPS[step]}</div>
|
||||
|
||||
{/* Step 1: 选择员工 */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{employees?.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{selectedEmployee && (
|
||||
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="font-medium">{selectedEmployee.name}({selectedEmployee.department})</div>
|
||||
<div>入职日期:{selectedEmployee.hireDate?.toString().slice(0, 10)}</div>
|
||||
<div>月工资:¥{selectedEmployee.monthlySalary.toLocaleString()}</div>
|
||||
{selectedEmployee.latestContract ? (
|
||||
<div>合同状态:{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}</div>
|
||||
) : (
|
||||
<div className="text-warning">⚠️ 无合同记录</div>
|
||||
)}
|
||||
{selectedEmployee.counts && (
|
||||
<div className="flex gap-3 flex-wrap mt-2">
|
||||
{selectedEmployee.counts.disciplinaryRecords > 0 && (
|
||||
<span className="text-danger">违纪记录:{selectedEmployee.counts.disciplinaryRecords}条</span>
|
||||
)}
|
||||
{selectedEmployee.counts.performanceRecords > 0 && (
|
||||
<span>绩效记录:{selectedEmployee.counts.performanceRecords}条</span>
|
||||
)}
|
||||
{selectedEmployee.counts.attendanceRecords > 0 && (
|
||||
<span>考勤记录:{selectedEmployee.counts.attendanceRecords}条</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{profile && suggestions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">📋 解聘方式建议</div>
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`px-3 py-2 rounded-md text-sm ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
|
||||
>
|
||||
<div className="font-medium">{s.label}</div>
|
||||
<div className="text-xs mt-0.5">{s.why}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{employeeId && !profile && (
|
||||
<div className="text-sm text-gray-400">加载员工档案中...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 解聘方式 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
{suggestions.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-md p-3 space-y-1">
|
||||
<div className="text-sm font-medium text-blue-700">💡 系统建议</div>
|
||||
{suggestions.filter((s) => s.reason).map((s, i) => (
|
||||
<div key={i} className="text-xs text-blue-600">
|
||||
{s.label}:{s.why}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{REASONS.map((r) => {
|
||||
const suggested = suggestions.find((s) => s.reason === r.value)
|
||||
return (
|
||||
<label
|
||||
key={r.value}
|
||||
className={`flex items-start gap-3 p-3 rounded-md border cursor-pointer hover:bg-gray-50 ${suggested ? 'border-primary bg-primary/5' : ''}`}
|
||||
>
|
||||
<input type="radio" name="reason" value={r.value} checked={reason === r.value} onChange={(e) => setReason(e.target.value)} className="mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
{r.label}
|
||||
{suggested && <span className="text-xs text-primary font-medium">推荐</span>}
|
||||
</div>
|
||||
{suggested && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">{suggested.why}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<Label>解聘日期</Label>
|
||||
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* 禁止解聘检查 */}
|
||||
{riskAssessment && riskAssessment.warnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{riskAssessment.warnings.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
|
||||
<input type="checkbox" checked={acknowledgeRisk} onChange={(e) => setAcknowledgeRisk(e.target.checked)} />
|
||||
我已了解风险,继续操作
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 合规检查 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
{checklistItems?.map((item) => (
|
||||
<label key={item.key} className="flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checklist[item.key] || false}
|
||||
onChange={(e) => setChecklist({ ...checklist, [item.key]: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: 费用结算 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>当地社平工资(选填)</Label>
|
||||
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
|
||||
</div>
|
||||
{costResult && (
|
||||
<div className="space-y-4">
|
||||
{/* 员工概况 */}
|
||||
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="font-medium">{selectedEmployee?.name}({selectedEmployee?.department})</div>
|
||||
<div>工作年限:{costResult.years}年{costResult.remainingMonths}个月</div>
|
||||
<div>月工资:¥{costResult.wage.toLocaleString()}/月</div>
|
||||
{costResult.capped && (
|
||||
<div className="text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 经济补偿金 / 赔偿金 */}
|
||||
{costResult.noComp ? (
|
||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-sm">
|
||||
员工过错解除,无需支付经济补偿金
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md p-4 space-y-2">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<Calculator className="w-4 h-4" />
|
||||
{costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">补偿月数:{costResult.cappedMonths}个月</div>
|
||||
<div className="text-sm text-gray-500">计算基数:¥{costResult.cappedWage.toLocaleString()}/月</div>
|
||||
{costResult.isIllegal && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">经济补偿金</span>
|
||||
<span>¥{costResult.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{costResult.isIllegal ? '赔偿金(×2)' : '补偿金'}</span>
|
||||
<span className={`text-lg font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
|
||||
¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
{costResult.noticePay > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">代通知金</span>
|
||||
<span>¥{costResult.noticePay.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{costResult.noticePay > 0 && (
|
||||
<div className="text-xs text-gray-400">含代通知金 ¥{costResult.noticePay.toLocaleString()}</div>
|
||||
)}
|
||||
{costResult.isIllegal && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
<span>违法解除劳动合同,按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 双倍工资(未签合同自动触发) */}
|
||||
{!costResult.hasContract && costResult.doubleMonths > 0 && (
|
||||
<div className="border border-warning rounded-md p-4 space-y-2">
|
||||
<div className="font-medium flex items-center gap-2 text-warning">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
未签劳动合同双倍工资
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">双倍工资起算:{costResult.doubleStartDate}</div>
|
||||
<div className="text-sm text-gray-500">双倍工资截止:{costResult.doubleEndDate}</div>
|
||||
<div className="text-sm text-gray-500">赔偿月数:{costResult.doubleMonths}个月</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">双倍工资赔偿</span>
|
||||
<span className="text-lg font-bold text-warning">¥{costResult.doublePay.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({costResult.doubleMonths}个月 × ¥{costResult.wage.toLocaleString()})</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-800 text-xs">
|
||||
<Info className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
<span>入职1个月未签合同,从第2个月起需付双倍工资,最多11个月</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合计 */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">合计应付</span>
|
||||
<span className="text-xl font-bold text-danger">¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!costResult.noComp && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>满1年补1个月,满6个月不满1年按1年算,不满6个月补半个月</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: 解聘材料 */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
{saveMutation.isError ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertTriangle className="w-12 h-12 text-danger mx-auto" />
|
||||
<div className="text-danger font-medium mt-2">保存失败</div>
|
||||
<div className="text-sm text-gray-500">{(saveMutation.error as any)?.response?.data?.error?.message || '请稍后重试'}</div>
|
||||
<Button onClick={() => setStep(3)} className="mt-4">返回修改</Button>
|
||||
</div>
|
||||
) : saveMutation.isPending ? (
|
||||
<div className="text-center py-8 text-gray-400">保存中...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 成功提示 */}
|
||||
<div className="flex items-center gap-2 text-safe">
|
||||
<Check className="w-5 h-5" />
|
||||
<span className="font-medium">解聘记录已保存,以下为完整解聘材料</span>
|
||||
</div>
|
||||
|
||||
{/* 打印按钮 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-1" />打印材料
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleReset}>
|
||||
新建解聘
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 1. 解聘通知书 */}
|
||||
<div className="border rounded-lg p-6 space-y-4 print:shadow-none">
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-bold">解除劳动合同通知书</h2>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 space-y-3">
|
||||
<p><strong>{selectedEmployee?.name}</strong> 先生/女士:</p>
|
||||
<p>
|
||||
您于 <strong>{selectedEmployee?.hireDate?.toString().slice(0, 10)}</strong> 入职我公司{selectedEmployee?.department}部门。
|
||||
因 <strong>{reasonLabel}</strong> 原因,公司决定于 <strong>{terminationDate}</strong> 起解除与您的劳动合同。
|
||||
</p>
|
||||
<p>
|
||||
解除依据:{reasonLegalBasis}
|
||||
</p>
|
||||
{costResult && !costResult.noComp && (
|
||||
<p>
|
||||
经济补偿金:补偿月数 <strong>{costResult.cappedMonths}</strong> 个月,计算基数 <strong>¥{costResult.cappedWage.toLocaleString()}/月</strong>,
|
||||
应付金额 <strong>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong>
|
||||
{costResult.noticePay > 0 && `(含代通知金 ¥${costResult.noticePay.toLocaleString()})`}
|
||||
。
|
||||
</p>
|
||||
)}
|
||||
{costResult && costResult.noComp && (
|
||||
<p>因员工过错解除,无需支付经济补偿金。</p>
|
||||
)}
|
||||
{costResult && !costResult.hasContract && costResult.doubleMonths > 0 && (
|
||||
<p>
|
||||
未签订劳动合同双倍工资:{costResult.doubleMonths}个月,合计 <strong>¥{costResult.doublePay.toLocaleString()}</strong>。
|
||||
</p>
|
||||
)}
|
||||
{costResult && (
|
||||
<p>合计应付金额:<strong>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong></p>
|
||||
)}
|
||||
<p>请于解除日期前办理工作交接手续,结清相关费用。</p>
|
||||
<div className="text-right mt-6 space-y-1">
|
||||
<p>公司(盖章)</p>
|
||||
<p className="text-gray-400">{new Date().toISOString().slice(0, 10)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 费用结算明细 */}
|
||||
{costResult && (
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="font-medium flex items-center gap-2"><Calculator className="w-4 h-4" />费用结算明细</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between"><span>工作年限</span><span>{costResult.years}年{costResult.remainingMonths}个月</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{costResult.wage.toLocaleString()}/月</span></div>
|
||||
{costResult.capped && <div className="text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>}
|
||||
{!costResult.noComp && (
|
||||
<>
|
||||
<div className="flex justify-between"><span>补偿月数</span><span>{costResult.cappedMonths}个月</span></div>
|
||||
<div className="flex justify-between"><span>计算基数</span><span>¥{costResult.cappedWage.toLocaleString()}/月</span></div>
|
||||
<div className="flex justify-between font-medium"><span>{costResult.isIllegal ? '违法解除赔偿金(×2)' : '经济补偿金'}</span><span>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
|
||||
{costResult.noticePay > 0 && <div className="flex justify-between"><span>代通知金</span><span>¥{costResult.noticePay.toLocaleString()}</span></div>}
|
||||
</>
|
||||
)}
|
||||
{!costResult.hasContract && costResult.doubleMonths > 0 && (
|
||||
<div className="flex justify-between text-warning"><span>未签合同双倍工资({costResult.doubleMonths}个月)</span><span>¥{costResult.doublePay.toLocaleString()}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>合计应付</span><span>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. 合规检查清单 */}
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="font-medium flex items-center gap-2"><Shield className="w-4 h-4" />合规检查清单</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
{checklistItems?.map((item) => (
|
||||
<div key={item.key} className="flex items-center gap-2">
|
||||
<span className={checklist[item.key] ? 'text-safe' : 'text-danger'}>
|
||||
{checklist[item.key] ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={checklist[item.key] ? '' : 'text-gray-500'}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{riskAssessment && riskAssessment.warnings.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{riskAssessment.warnings.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-danger">
|
||||
<AlertTriangle className="w-3 h-3" />{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 仲裁证据链 */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium flex items-center gap-2"><FileText className="w-4 h-4" />仲裁证据链</h3>
|
||||
{evidenceChain ? (
|
||||
<>
|
||||
<div className="text-xs text-gray-500">
|
||||
共 {evidenceChain.summary?.total || 0} 条证据,
|
||||
已签确认 {evidenceChain.summary?.signed || 0} 条,
|
||||
未签 {evidenceChain.summary?.unsigned || 0} 条
|
||||
</div>
|
||||
{(() => {
|
||||
const grouped = (evidenceChain.evidence || []).reduce((acc: Record<string, any[]>, e: any) => {
|
||||
(acc[e.category] = acc[e.category] || []).push(e)
|
||||
return acc
|
||||
}, {})
|
||||
return Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category} className="space-y-1">
|
||||
<div className="text-sm font-medium text-gray-700">{category as string}</div>
|
||||
{(items as any[]).map((e: any, i: number) => (
|
||||
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{e.title}</span>
|
||||
{e.acknowledged === true && <span className="text-safe">✓已签</span>}
|
||||
{e.acknowledged === false && <span className="text-danger">✗未签</span>}
|
||||
</div>
|
||||
<div className="text-gray-400">{e.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">加载证据链中...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导航按钮 */}
|
||||
{step < 4 && (
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setStep(Math.max(0, step - 1))}
|
||||
disabled={step === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />上一步
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
|
||||
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
<Shield className="w-4 h-4 mr-1" />确认保存
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Building2, Eye, EyeOff } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
const schema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
}).refine((data) => data.newPassword.length >= 8, {
|
||||
message: '密码至少8位',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/auth/reset-password', data)
|
||||
setSuccess(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '重置失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h1 className="text-lg font-semibold mb-4">重置密码</h1>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="text-green-600 mb-3">密码已重置成功</div>
|
||||
<Link to="/login" className="text-primary hover:underline text-sm">返回登录</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入注册手机号"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="至少8位"
|
||||
{...register('newPassword')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.newPassword && <p className="text-xs text-red-500 mt-1">{errors.newPassword.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? '重置中...' : '重置密码'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<Link to="/login" className="text-primary hover:underline">返回登录</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Building2, Eye, EyeOff } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
const schema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { setAuth } = useAuthStore()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
phone: '13800000001',
|
||||
password: '12345678',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/login', data) as any
|
||||
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken)
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h1 className="text-lg font-semibold mb-4">登录</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
{...register('password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<Link to="/forgot-password" className="text-primary hover:underline">忘记密码?</Link>
|
||||
<Link to="/register" className="text-primary hover:underline">注册新企业</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Building2, Eye, EyeOff } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: '两次密码不一致',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate()
|
||||
const { setAuth } = useAuthStore()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/register', data) as any
|
||||
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken)
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '注册失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h1 className="text-lg font-semibold mb-4">注册新企业</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label>企业名称</Label>
|
||||
<Input
|
||||
placeholder="请输入企业名称"
|
||||
{...register('orgName')}
|
||||
/>
|
||||
{errors.orgName && <p className="text-xs text-red-500 mt-1">{errors.orgName.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="至少8位"
|
||||
{...register('password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>确认密码</Label>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请再次输入密码"
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && <p className="text-xs text-red-500 mt-1">{errors.confirmPassword.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
已有账号?<Link to="/login" className="text-primary hover:underline">去登录</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { PenTool, Check, AlertCircle } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
export default function ContractConfirm() {
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
api.get(`/portal/contract-confirm/${token}`).then((res: any) => {
|
||||
setData(res.data)
|
||||
}).catch((err: any) => {
|
||||
setError(err.response?.data?.error?.message || '链接无效或已过期')
|
||||
}).finally(() => setLoading(false))
|
||||
} else {
|
||||
setError('缺少 token 参数')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.post('/portal/contract-confirm', { token, agreed: true })
|
||||
setConfirmed(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '确认失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-lg font-semibold mb-2">合同签署确认成功</h1>
|
||||
<p className="text-sm text-gray-500">已记录您的签署确认时间和 IP 地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<PenTool className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">合同签署确认</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
) : data ? (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.orgName} 与 {data.employeeName} 的劳动合同
|
||||
</div>
|
||||
|
||||
{data.contract && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="合同类型" value={data.contract.contractType === 'FIXED' ? '固定期限' : data.contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
|
||||
{data.contract.contractYears > 0 && <Row label="合同期限" value={`${data.contract.contractYears}年`} />}
|
||||
<Row label="合同开始" value={new Date(data.contract.startDate).toISOString().slice(0, 10)} />
|
||||
{data.contract.endDate && <Row label="合同结束" value={new Date(data.contract.endDate).toISOString().slice(0, 10)} />}
|
||||
{data.contract.probationMonths > 0 && <Row label="试用期" value={`${data.contract.probationMonths}个月`} />}
|
||||
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${Number(data.contract.probationSalary).toLocaleString()}`} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
||||
我已阅读合同内容,确认签署
|
||||
</label>
|
||||
|
||||
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting}>
|
||||
{submitting ? '确认中...' : '确认签署'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center">📌 确认后将记录签署时间和 IP 地址</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, AlertCircle, Check } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
|
||||
const portalApi = api.create({ baseURL: '/api/v1/portal' })
|
||||
portalApi.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('portalToken')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
export default function MyContract() {
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['my-contract'],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/contract') as any
|
||||
return res.data?.data ?? null
|
||||
},
|
||||
})
|
||||
|
||||
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
|
||||
const contract = data
|
||||
|
||||
const daysToExpire = contract?.endDate
|
||||
? Math.floor((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">我的劳动合同</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
<Link to="/portal/payslip" className="text-sm text-primary">工资条</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !contract ? (
|
||||
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 到期提醒 */}
|
||||
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
您的合同还有 {daysToExpire} 天到期
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="合同类型" value={contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
|
||||
<Row label="签订方式" value={contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'} />
|
||||
{contract.signDate && <Row label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />}
|
||||
<Row label="合同开始" value={new Date(contract.startDate).toISOString().slice(0, 10)} />
|
||||
{contract.endDate && <Row label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />}
|
||||
{contract.contractYears > 0 && <Row label="合同期限" value={`${contract.contractYears}年`} />}
|
||||
{contract.probationMonths > 0 && <Row label="试用期" value={`${contract.probationMonths}个月`} />}
|
||||
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${Number(contract.probationSalary).toLocaleString()}`} />}
|
||||
</div>
|
||||
|
||||
{/* 签署确认记录 */}
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-sm mb-2">签署记录</h3>
|
||||
{contract.attachmentName?.startsWith('confirmed:') ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" />
|
||||
已确认签署({new Date(contract.attachmentName.slice(10)).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">暂无签署确认记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ClipboardList, Check } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
|
||||
export default function Onboarding() {
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
idCard: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
address: '',
|
||||
bankCard: '',
|
||||
bankName: '',
|
||||
})
|
||||
|
||||
// 获取链接信息
|
||||
useState(() => {
|
||||
if (token) {
|
||||
api.get(`/portal/onboarding/${token}`).then((res: any) => {
|
||||
setOrgName(res.data.orgName)
|
||||
}).catch((err: any) => {
|
||||
setError(err.response?.data?.error?.message || '链接无效')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/portal/onboarding', { ...form, token })
|
||||
setSubmitted(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '提交失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-lg font-semibold mb-2">信息提交成功</h1>
|
||||
<p className="text-sm text-gray-500">HR 将审核您的信息,请耐心等待。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<ClipboardList className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">入职信息填报</h1>
|
||||
</div>
|
||||
|
||||
{orgName && (
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
欢迎加入 {orgName}!请填写以下信息:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="请输入姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号 *</Label>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>紧急联系人</Label>
|
||||
<Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>紧急联系电话</Label>
|
||||
<Input type="tel" value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>住址</Label>
|
||||
<Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>银行卡号</Label>
|
||||
<Input value={form.bankCard} onChange={(e) => setForm({ ...form, bankCard: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>开户行</Label>
|
||||
<Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.idCard}>
|
||||
{loading ? '提交中...' : '提交'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center">📌 提交后 HR 将审核您的信息</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { DollarSign, Check } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
|
||||
const portalApi = api.create({ baseURL: '/api/v1/portal' })
|
||||
portalApi.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('portalToken')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
export default function Payslip() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['payslip', month],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/payslip', { params: { month } }) as any
|
||||
return res.data?.data ?? null
|
||||
},
|
||||
})
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (id: string) => portalApi.post(`/payslip/${id}/confirm`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip'] }),
|
||||
})
|
||||
|
||||
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">我的工资条</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
<Link to="/portal/contract" className="text-sm text-primary">我的合同</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !data ? (
|
||||
<EmptyState title="暂无工资条" description={`该月份(${month})暂无工资记录`} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">基本工资</span>
|
||||
<span className="font-medium">¥{Number(data.baseSalary).toLocaleString()}</span>
|
||||
</div>
|
||||
{data.overtimePay > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">加班费</span>
|
||||
<span className="font-medium">¥{Number(data.overtimePay).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.allowance > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">津贴</span>
|
||||
<span className="font-medium">¥{Number(data.allowance).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.deduction > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">扣款</span>
|
||||
<span className="font-medium text-danger">-¥{Number(data.deduction).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">应发合计</span>
|
||||
<span className="text-xl font-bold text-primary">¥{Number(data.totalPay).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.confirmedAt ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" /> 已确认({new Date(data.confirmedAt).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => confirmMutation.mutate(data.id)}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{confirmMutation.isPending ? '确认中...' : '确认已阅'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
export default function PortalLogin() {
|
||||
const navigate = useNavigate()
|
||||
const [mode, setMode] = useState<LoginMode>('password')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [displayedCode, setDisplayedCode] = useState('')
|
||||
|
||||
const handlePasswordLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/portal/login', { phone, password }) as any
|
||||
localStorage.setItem('portalToken', res.data.token)
|
||||
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
|
||||
navigate('/portal/payslip')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/portal/send-code', { phone }) as any
|
||||
setCodeSent(true)
|
||||
setDisplayedCode(res.data.code)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/portal/verify-code', { phone, code }) as any
|
||||
localStorage.setItem('portalToken', res.data.token)
|
||||
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
|
||||
navigate('/portal/payslip')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手 — 员工端</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex border-b mb-4">
|
||||
<button
|
||||
onClick={() => { setMode('password'); setError('') }}
|
||||
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'password' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>密码登录</button>
|
||||
<button
|
||||
onClick={() => { setMode('code'); setError('') }}
|
||||
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'code' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>验证码登录</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
{mode === 'password' ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<Input type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label>验证码</Label>
|
||||
<Input placeholder="6位验证码" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button variant="secondary" onClick={handleSendCode} disabled={!phone || codeSent}>
|
||||
{codeSent ? '已发送' : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{codeSent && displayedCode && (
|
||||
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
验证码:{displayedCode}(开发阶段直接显示,生产环境将发送短信)
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
orgId: string
|
||||
name: string
|
||||
phone: string
|
||||
role: 'ADMIN' | 'HR' | 'VIEWER'
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
isAuthenticated: boolean
|
||||
setAuth: (user: User, accessToken: string, refreshToken: string) => void
|
||||
updateToken: (accessToken: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (user, accessToken, refreshToken) =>
|
||||
set({ user, accessToken, refreshToken, isAuthenticated: true }),
|
||||
updateToken: (accessToken) => set({ accessToken }),
|
||||
logout: () =>
|
||||
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }),
|
||||
}),
|
||||
{ name: 'auth-storage' },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean
|
||||
data: T
|
||||
error: { code: string; message: string } | null
|
||||
}
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
plan: 'FREE' | 'PRO' | 'ENTERPRISE'
|
||||
maxEmployees: number
|
||||
city: string | null
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
orgId: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string | null
|
||||
role: 'ADMIN' | 'HR' | 'VIEWER'
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string
|
||||
orgId: string
|
||||
name: string
|
||||
department: string
|
||||
hireDate: string
|
||||
monthlySalary: string
|
||||
status: 'ACTIVE' | 'RESIGNED'
|
||||
gender: string | null
|
||||
phone: string | null
|
||||
isPregnant: boolean
|
||||
isInMedicalPeriod: boolean
|
||||
isWorkInjured: boolean
|
||||
contracts: LaborContract[]
|
||||
}
|
||||
|
||||
export interface LaborContract {
|
||||
id: string
|
||||
employeeId: string
|
||||
signDate: string | null
|
||||
startDate: string
|
||||
endDate: string | null
|
||||
contractType: 'FIXED' | 'UNFIXED' | 'UNSIGNED'
|
||||
signMethod: 'PAPER' | 'ELECTRONIC'
|
||||
contractYears: number
|
||||
probationMonths: number
|
||||
probationSalary: number
|
||||
renewalCount: number
|
||||
attachmentName: string | null
|
||||
attachmentUrl: string | null
|
||||
electronicContractNo: string | null
|
||||
electronicContractUrl: string | null
|
||||
}
|
||||
|
||||
export interface RiskItem {
|
||||
id: string
|
||||
orgId: string
|
||||
employeeId: string | null
|
||||
type: 'CONTRACT' | 'SALARY' | 'TERMINATION'
|
||||
level: 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
status: 'PENDING' | 'RESOLVED' | 'IGNORED'
|
||||
title: string
|
||||
description: string
|
||||
actionUrl: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
greeting: string
|
||||
stats: {
|
||||
employeeCount: number
|
||||
highRiskCount: number
|
||||
todoCount: number
|
||||
monthlyOvertimePay: number
|
||||
}
|
||||
todos: {
|
||||
id: string
|
||||
level: 'high' | 'medium' | 'low'
|
||||
title: string
|
||||
description: string
|
||||
actionUrl: string
|
||||
}[]
|
||||
resolvedTodos: {
|
||||
id: string
|
||||
level: 'high' | 'medium' | 'low'
|
||||
title: string
|
||||
description: string
|
||||
actionUrl: string
|
||||
resolvedAt: string | null
|
||||
}[]
|
||||
riskDistribution: {
|
||||
contract: number
|
||||
salary: number
|
||||
termination: number
|
||||
}
|
||||
aiPrediction: {
|
||||
risks: unknown[]
|
||||
suggestion: string
|
||||
} | null
|
||||
payrollSummary: {
|
||||
month: string
|
||||
employeeCount: number
|
||||
payslipCount: number
|
||||
confirmedPayslips: number
|
||||
unconfirmedPayslips: number
|
||||
baseSalary: number
|
||||
overtimePay: number
|
||||
allowance: number
|
||||
deduction: number
|
||||
totalPay: number
|
||||
socialOrg: number
|
||||
socialEmp: number
|
||||
housingOrg: number
|
||||
housingEmp: number
|
||||
estimatedTax: number
|
||||
severancePay: number
|
||||
orgTotalCost: number
|
||||
empNetPay: number
|
||||
}
|
||||
monthlyActivities: {
|
||||
month: string
|
||||
newContracts: number
|
||||
terminations: number
|
||||
disciplinaryActions: number
|
||||
attendanceRecords: number
|
||||
overtimeHours: number
|
||||
overtimePay: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface TerminationRecord {
|
||||
id: string
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
reason: 'NEGOTIATED' | 'FAULT' | 'NONFAULT' | 'LAYOFF' | 'EXPIRED'
|
||||
terminationDate: string
|
||||
compensation: number
|
||||
riskLevel: 'SAFE' | 'WARNING' | 'DANGER'
|
||||
checklist: { item: string; passed: boolean; remark?: string }[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Payslip {
|
||||
id: string
|
||||
month: string
|
||||
baseSalary: number
|
||||
overtimePay: number
|
||||
weekdayOvertimePay: number
|
||||
weekendOvertimePay: number
|
||||
holidayOvertimePay: number
|
||||
totalPay: number
|
||||
confirmedAt: string | null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#2563EB',
|
||||
light: '#3B82F6',
|
||||
dark: '#1D4ED8',
|
||||
},
|
||||
danger: '#DC2626',
|
||||
warning: '#F59E0B',
|
||||
safe: '#16A34A',
|
||||
surface: '#F8FAFC',
|
||||
},
|
||||
maxWidth: {
|
||||
content: '960px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
[build]
|
||||
command = "cd frontend && npm install && npm run build"
|
||||
publish = "frontend/dist"
|
||||
|
||||
[[redirects]]
|
||||
from = "/api/*"
|
||||
to = "http://localhost:3000/api/:splat"
|
||||
status = 200
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
Reference in New Issue
Block a user